A2A Agent Card Validation & UI Import — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable AgentLens to validate A2A Agent Cards (v0.3/v1.0), expose a validation endpoint, and provide a Dashboard modal to import/register cards with spec compliance checking.
Architecture: Extend the domain model with a TypedMetadata interface (inspired by Product Archetype's FeatureValueConstraint) for polymorphic protocol-specific data. Enhance the A2A parser to detect spec version and extract extensions/security/interfaces. Add a dry-run validation endpoint. Build a 4-step registration modal in React with shadcn/ui.
Tech Stack: Go 1.26 (Chi, GORM), React 18 (TypeScript, shadcn/ui, Tailwind), Playwright E2E
Design Spec: docs/superpowers/specs/2026-04-01-a2a-card-validation-design.md
File Map¶
New Files¶
| File | Responsibility |
|---|---|
internal/model/typed_metadata.go |
TypedMetadata interface, registry, JSON serialization helpers |
internal/model/typed_metadata_test.go |
Round-trip serialization, validation tests |
internal/model/a2a_metadata.go |
A2A-specific types: Extension, SecurityScheme, Interface, Signature |
internal/model/a2a_metadata_test.go |
A2A metadata Validate() tests |
internal/api/validate_handler.go |
POST /api/v1/catalog/validate handler |
internal/api/validate_handler_test.go |
Validation endpoint tests |
plugins/parsers/a2a/validation.go |
ValidateCard() returning structured errors/warnings |
plugins/parsers/a2a/validation_test.go |
Parser validation tests (v0.3, v1.0, legacy, invalid) |
plugins/parsers/a2a/testdata/a2a_v03_card.json |
Test fixture: full v0.3 card |
plugins/parsers/a2a/testdata/a2a_v10_card.json |
Test fixture: full v1.0 card |
plugins/parsers/a2a/testdata/a2a_legacy_card.json |
Test fixture: legacy card (url only) |
plugins/parsers/a2a/testdata/a2a_invalid_card.json |
Test fixture: missing required fields |
web/src/components/RegisterAgentDialog.tsx |
4-step registration modal |
web/src/components/CardPreview.tsx |
Reusable card preview (modal + detail view) |
Modified Files¶
| File | Changes |
|---|---|
internal/model/agent.go |
Add SpecVersion, TypedMeta fields + SyncToDB/SyncFromDB/MarshalJSON |
internal/db/migrations.go |
Migration 005: add spec_version, typed_meta columns |
plugins/parsers/a2a/a2a.go |
Extend a2aCard struct, update Parse() to extract new fields |
internal/api/router.go |
Register POST /catalog/validate route |
internal/api/handlers.go |
No changes needed — validate handler uses a2a.ValidateCard() directly |
web/src/types.ts |
Add TypedMeta, ValidationResult, CardPreview types |
web/src/api.ts |
Add validateAgentCard(), createAgentFromCard() functions |
web/src/components/CatalogList.tsx |
Add "Register Agent" button |
web/src/components/EntryDetail.tsx |
Add Extensions, Security, SpecVersion sections |
e2e/tests/helpers.ts |
Add validateAgentCard() helper |
e2e/tests/catalog.spec.ts |
Add validation and registration E2E tests |
Task 1: TypedMetadata Interface & Registry¶
Files:
- Create: internal/model/typed_metadata.go
- Create: internal/model/typed_metadata_test.go
- [ ] Step 1: Write the failing test for TypedMetadata serialization round-trip
// internal/model/typed_metadata_test.go
package model_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/PawelHaracz/agentlens/internal/model"
)
func TestMarshalTypedMetadata(t *testing.T) {
items := []model.TypedMetadata{
&model.A2AExtension{URI: "urn:example:ext", Required: true},
&model.A2ASecurityScheme{Type: "bearer", Method: "header", Name: "Authorization"},
}
data, err := model.MarshalTypedMetaJSON(items)
require.NoError(t, err)
parsed, err := model.UnmarshalTypedMetaJSON(data)
require.NoError(t, err)
require.Len(t, parsed, 2)
ext, ok := parsed[0].(*model.A2AExtension)
require.True(t, ok)
assert.Equal(t, "urn:example:ext", ext.URI)
assert.True(t, ext.Required)
sec, ok := parsed[1].(*model.A2ASecurityScheme)
require.True(t, ok)
assert.Equal(t, "bearer", sec.Type)
}
func TestUnmarshalTypedMetadata_EmptyArray(t *testing.T) {
parsed, err := model.UnmarshalTypedMetaJSON([]byte("[]"))
require.NoError(t, err)
assert.Empty(t, parsed)
}
func TestUnmarshalTypedMetadata_UnknownKind(t *testing.T) {
data := []byte(`[{"kind":"unknown.type","foo":"bar"}]`)
parsed, err := model.UnmarshalTypedMetaJSON(data)
require.NoError(t, err)
assert.Empty(t, parsed, "unknown kinds should be silently skipped")
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/model/ -run TestMarshalTypedMetadata -v
Expected: compilation error — model.TypedMetadata undefined
- [ ] Step 3: Implement TypedMetadata interface, registry, and serialization
// internal/model/typed_metadata.go
package model
import (
"encoding/json"
"fmt"
)
// TypedMetadata is the polymorphic base for protocol-specific structured data.
// Inspired by the Product Archetype's FeatureValueConstraint pattern.
type TypedMetadata interface {
Kind() string
Validate() error
}
// typedMetaRegistry maps kind strings to factory functions for deserialization.
var typedMetaRegistry = map[string]func() TypedMetadata{}
// RegisterTypedMeta registers a TypedMetadata factory for a given kind.
func RegisterTypedMeta(kind string, factory func() TypedMetadata) {
typedMetaRegistry[kind] = factory
}
// typedMetaEnvelope wraps a TypedMetadata for JSON serialization with kind discriminator.
type typedMetaEnvelope struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"-"`
}
// MarshalTypedMetaJSON serializes a slice of TypedMetadata to JSON with kind discriminators.
func MarshalTypedMetaJSON(items []TypedMetadata) ([]byte, error) {
if len(items) == 0 {
return []byte("[]"), nil
}
var result []json.RawMessage
for _, item := range items {
data, err := json.Marshal(item)
if err != nil {
return nil, fmt.Errorf("marshaling typed metadata %s: %w", item.Kind(), err)
}
// Inject "kind" field into the JSON object.
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("re-parsing typed metadata: %w", err)
}
kindBytes, _ := json.Marshal(item.Kind())
m["kind"] = kindBytes
merged, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("merging kind field: %w", err)
}
result = append(result, merged)
}
return json.Marshal(result)
}
// UnmarshalTypedMetaJSON deserializes a JSON array into concrete TypedMetadata types.
func UnmarshalTypedMetaJSON(data []byte) ([]TypedMetadata, error) {
var raws []json.RawMessage
if err := json.Unmarshal(data, &raws); err != nil {
return nil, fmt.Errorf("unmarshaling typed metadata array: %w", err)
}
var result []TypedMetadata
for _, raw := range raws {
var peek struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(raw, &peek); err != nil {
continue
}
factory, ok := typedMetaRegistry[peek.Kind]
if !ok {
continue // silently skip unknown kinds for forward compatibility
}
item := factory()
if err := json.Unmarshal(raw, item); err != nil {
continue
}
result = append(result, item)
}
return result, nil
}
- [ ] Step 4: Run tests to verify they pass
Run: go test ./internal/model/ -run TestMarshalTypedMetadata -v && go test ./internal/model/ -run TestUnmarshalTypedMetadata -v
Expected: PASS (after A2A types are created in Task 2)
- [ ] Step 5: Commit
git add internal/model/typed_metadata.go internal/model/typed_metadata_test.go
git commit -m "feat: add TypedMetadata interface and serialization registry"
Task 2: A2A Metadata Types¶
Files:
- Create: internal/model/a2a_metadata.go
- Create: internal/model/a2a_metadata_test.go
- [ ] Step 1: Write failing tests for A2A metadata validation
// internal/model/a2a_metadata_test.go
package model_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/PawelHaracz/agentlens/internal/model"
)
func TestA2AExtension_Kind(t *testing.T) {
ext := &model.A2AExtension{URI: "urn:test", Required: true}
assert.Equal(t, "a2a.extension", ext.Kind())
}
func TestA2AExtension_Validate(t *testing.T) {
tests := []struct {
name string
ext model.A2AExtension
wantErr bool
}{
{"valid", model.A2AExtension{URI: "urn:test", Required: true}, false},
{"missing uri", model.A2AExtension{URI: "", Required: true}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.ext.Validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestA2ASecurityScheme_Validate(t *testing.T) {
tests := []struct {
name string
scheme model.A2ASecurityScheme
wantErr bool
}{
{"valid bearer", model.A2ASecurityScheme{Type: "bearer", Method: "header", Name: "Authorization"}, false},
{"missing type", model.A2ASecurityScheme{Type: "", Method: "header"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.scheme.Validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestA2AInterface_Validate(t *testing.T) {
tests := []struct {
name string
iface model.A2AInterface
wantErr bool
}{
{"valid", model.A2AInterface{URL: "https://example.com/a2a", Binding: "jsonrpc"}, false},
{"missing url", model.A2AInterface{URL: ""}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.iface.Validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestA2ASignature_Validate(t *testing.T) {
valid := model.A2ASignature{Algorithm: "RS256", KeyID: "key-1"}
assert.NoError(t, valid.Validate())
invalid := model.A2ASignature{Algorithm: ""}
assert.Error(t, invalid.Validate())
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/model/ -run TestA2A -v
Expected: compilation error — model.A2AExtension undefined
- [ ] Step 3: Implement A2A metadata types with init() registration
// internal/model/a2a_metadata.go
package model
import "fmt"
func init() {
RegisterTypedMeta("a2a.extension", func() TypedMetadata { return &A2AExtension{} })
RegisterTypedMeta("a2a.security_scheme", func() TypedMetadata { return &A2ASecurityScheme{} })
RegisterTypedMeta("a2a.interface", func() TypedMetadata { return &A2AInterface{} })
RegisterTypedMeta("a2a.signature", func() TypedMetadata { return &A2ASignature{} })
}
// A2AExtension represents an A2A agent card extension.
type A2AExtension struct {
URI string `json:"uri"`
Required bool `json:"required"`
}
func (e *A2AExtension) Kind() string { return "a2a.extension" }
func (e *A2AExtension) Validate() error {
if e.URI == "" {
return fmt.Errorf("extension uri is required")
}
return nil
}
// A2ASecurityScheme represents an A2A security scheme.
type A2ASecurityScheme struct {
Type string `json:"type"`
Method string `json:"method,omitempty"`
Name string `json:"name,omitempty"`
}
func (s *A2ASecurityScheme) Kind() string { return "a2a.security_scheme" }
func (s *A2ASecurityScheme) Validate() error {
if s.Type == "" {
return fmt.Errorf("security scheme type is required")
}
return nil
}
// A2AInterface represents a supported interface endpoint.
type A2AInterface struct {
URL string `json:"url"`
Binding string `json:"binding,omitempty"`
}
func (i *A2AInterface) Kind() string { return "a2a.interface" }
func (i *A2AInterface) Validate() error {
if i.URL == "" {
return fmt.Errorf("interface url is required")
}
return nil
}
// A2ASignature represents a signature block.
type A2ASignature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"keyId,omitempty"`
}
func (s *A2ASignature) Kind() string { return "a2a.signature" }
func (s *A2ASignature) Validate() error {
if s.Algorithm == "" {
return fmt.Errorf("signature algorithm is required")
}
return nil
}
- [ ] Step 4: Run all model tests
Run: go test ./internal/model/ -v
Expected: ALL PASS (including Task 1 round-trip tests which now have the registered types)
- [ ] Step 5: Commit
git add internal/model/a2a_metadata.go internal/model/a2a_metadata_test.go
git commit -m "feat: add A2A typed metadata implementations (extension, security, interface, signature)"
Task 3: Extend CatalogEntry Model + Migration¶
Files:
- Modify: internal/model/agent.go
- Modify: internal/db/migrations.go
- [ ] Step 1: Write failing test for CatalogEntry with new fields
// Add to internal/model/typed_metadata_test.go
func TestCatalogEntry_SyncTypedMeta_RoundTrip(t *testing.T) {
entry := &model.CatalogEntry{
ID: "test-1",
DisplayName: "Test",
Protocol: model.ProtocolA2A,
SpecVersion: "1.0",
TypedMeta: []model.TypedMetadata{
&model.A2AExtension{URI: "urn:test", Required: true},
&model.A2AInterface{URL: "https://example.com", Binding: "jsonrpc"},
},
}
entry.SyncToDB()
// Verify JSON was produced
assert.NotEqual(t, "[]", entry.TypedMetaJSON)
assert.Equal(t, "1.0", entry.SpecVersion)
// Round-trip via SyncFromDB
restored := &model.CatalogEntry{
SpecVersion: entry.SpecVersion,
TypedMetaJSON: entry.TypedMetaJSON,
}
restored.SyncFromDB()
require.Len(t, restored.TypedMeta, 2)
ext, ok := restored.TypedMeta[0].(*model.A2AExtension)
require.True(t, ok)
assert.Equal(t, "urn:test", ext.URI)
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/model/ -run TestCatalogEntry_SyncTypedMeta -v
Expected: compilation error — entry.SpecVersion undefined
- [ ] Step 3: Add SpecVersion and TypedMeta to CatalogEntry
In internal/model/agent.go, add to the CatalogEntry struct:
After line 80 (Metadata map[string]string ...), add:
SpecVersion string `json:"spec_version,omitempty" gorm:"type:text;not null;default:''"`
TypedMeta []TypedMetadata `json:"typed_meta,omitempty" gorm:"-"`
After line 92 (LastSeen time.Time ...), add:
TypedMetaJSON string `json:"-" gorm:"column:typed_meta;type:text;not null;default:'[]'"`
In SyncToDB() method, before the closing brace, add:
if b, err := MarshalTypedMetaJSON(e.TypedMeta); err == nil {
e.TypedMetaJSON = string(b)
}
In SyncFromDB() method, before the Validity reconstruction, add:
if e.TypedMetaJSON != "" {
e.TypedMeta, _ = UnmarshalTypedMetaJSON([]byte(e.TypedMetaJSON))
}
In MarshalJSON(), update the anonymous struct to include:
SpecVersion string `json:"spec_version,omitempty"`
TypedMeta []TypedMetadata `json:"typed_meta,omitempty"`
SpecVersion: e.SpecVersion,
TypedMeta: e.TypedMeta,
- [ ] Step 4: Add migration 005
In internal/db/migrations.go, add to AllMigrations() slice:
migration005TypedMetadata(),
Add the migration function:
func migration005TypedMetadata() Migration {
return Migration{
Version: 5,
Description: "add spec_version and typed_meta columns to catalog_entries",
Up: func(tx *gorm.DB) error {
if err := tx.Exec("ALTER TABLE catalog_entries ADD COLUMN spec_version TEXT NOT NULL DEFAULT ''").Error; err != nil {
return err
}
return tx.Exec("ALTER TABLE catalog_entries ADD COLUMN typed_meta TEXT NOT NULL DEFAULT '[]'").Error
},
}
}
- [ ] Step 5: Run all model and store tests
Run: go test ./internal/model/ -v && go test ./internal/store/ -v && go test ./internal/db/ -v
Expected: ALL PASS
- [ ] Step 6: Commit
git add internal/model/agent.go internal/db/migrations.go internal/model/typed_metadata_test.go
git commit -m "feat: extend CatalogEntry with SpecVersion and TypedMeta, add migration 005"
Task 4: Test Fixtures¶
Files:
- Create: plugins/parsers/a2a/testdata/a2a_v03_card.json
- Create: plugins/parsers/a2a/testdata/a2a_v10_card.json
- Create: plugins/parsers/a2a/testdata/a2a_legacy_card.json
- Create: plugins/parsers/a2a/testdata/a2a_invalid_card.json
- [ ] Step 1: Create v0.3 card fixture
// plugins/parsers/a2a/testdata/a2a_v03_card.json
{
"name": "Weather Agent v0.3",
"description": "Provides weather forecasts",
"url": "https://weather.example.com/a2a",
"version": "2.1.0",
"supportsExtendedAgentCard": true,
"provider": {
"organization": "WeatherCorp",
"url": "https://weathercorp.example.com"
},
"supportedInterfaces": [
{"url": "https://weather.example.com/a2a", "binding": "jsonrpc"},
{"url": "https://weather.example.com/http", "binding": "http"}
],
"capabilities": {
"extensions": [
{"uri": "urn:weather:realtime", "required": true},
{"uri": "urn:weather:historical", "required": false}
]
},
"securitySchemes": [
{"type": "bearer", "method": "header", "name": "Authorization"},
{"type": "apiKey", "method": "query", "name": "api_key"}
],
"signatures": [
{"algorithm": "RS256", "keyId": "weather-key-1"}
],
"skills": [
{
"id": "get-forecast",
"name": "Get Forecast",
"description": "Returns weather forecast for a location",
"tags": ["weather", "forecast"],
"inputModes": ["text"],
"outputModes": ["text", "json"]
},
{
"id": "get-alerts",
"name": "Get Alerts",
"description": "Returns active weather alerts",
"tags": ["weather", "alerts"],
"inputModes": ["text"],
"outputModes": ["text"]
}
]
}
- [ ] Step 2: Create v1.0 card fixture
// plugins/parsers/a2a/testdata/a2a_v10_card.json
{
"name": "Translation Agent v1.0",
"description": "Multi-language translation service",
"version": "3.0.0",
"provider": {
"organization": "LangTech",
"url": "https://langtech.example.com"
},
"supportedInterfaces": [
{"url": "https://translate.example.com/a2a", "binding": "jsonrpc"}
],
"capabilities": {
"extensions": [
{"uri": "urn:translate:glossary", "required": true},
{"uri": "urn:translate:memory", "required": false},
{"uri": "urn:translate:context", "required": false}
],
"supportsExtendedAgentCard": true
},
"securitySchemes": [
{"type": "oauth2", "method": "header", "name": "Authorization"},
{"type": "mtls"}
],
"signatures": [
{"algorithm": "ES256", "keyId": "lang-key-1"}
],
"skills": [
{
"id": "translate-text",
"name": "Translate Text",
"description": "Translates text between languages",
"tags": ["translation", "text"],
"inputModes": ["text"],
"outputModes": ["text"]
},
{
"id": "detect-language",
"name": "Detect Language",
"description": "Detects the language of input text",
"tags": ["detection"],
"inputModes": ["text"],
"outputModes": ["json"]
}
]
}
- [ ] Step 3: Create legacy card fixture (url only, no supportedInterfaces)
// plugins/parsers/a2a/testdata/a2a_legacy_card.json
{
"name": "Legacy Agent",
"description": "An older agent card format",
"url": "https://legacy.example.com/agent",
"version": "1.0.0",
"provider": {
"organization": "OldCorp"
},
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the input",
"inputModes": ["text"],
"outputModes": ["text"]
}
]
}
- [ ] Step 4: Create invalid card fixture
// plugins/parsers/a2a/testdata/a2a_invalid_card.json
{
"description": "Missing name and endpoints",
"version": "1.0.0",
"capabilities": {
"extensions": [
{"required": true},
{"uri": "urn:valid:ext", "required": false}
]
},
"skills": [
{
"name": "Incomplete Skill",
"inputModes": ["text"]
},
{
"id": "valid-skill",
"name": "Valid Skill",
"description": "This skill is valid"
}
]
}
- [ ] Step 5: Commit
git add plugins/parsers/a2a/testdata/
git commit -m "test: add A2A card test fixtures (v0.3, v1.0, legacy, invalid)"
Task 5: A2A Parser Validation Logic¶
Files:
- Create: plugins/parsers/a2a/validation.go
- Create: plugins/parsers/a2a/validation_test.go
- [ ] Step 1: Write failing tests for ValidateCard
// plugins/parsers/a2a/validation_test.go
package a2a_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/PawelHaracz/agentlens/plugins/parsers/a2a"
)
func readFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile("testdata/" + name)
require.NoError(t, err)
return data
}
func TestValidateCard_V03(t *testing.T) {
result := a2a.ValidateCard(readFixture(t, "a2a_v03_card.json"))
assert.True(t, result.Valid)
assert.Equal(t, "0.3", result.SpecVersion)
assert.Empty(t, result.Errors)
assert.NotNil(t, result.Preview)
assert.Equal(t, "Weather Agent v0.3", result.Preview.DisplayName)
assert.Equal(t, 2, result.Preview.SkillsCount)
assert.Equal(t, 2, result.Preview.ExtensionsCount)
assert.Contains(t, result.Preview.SecuritySchemes, "bearer")
assert.Contains(t, result.Preview.SecuritySchemes, "apiKey")
assert.Len(t, result.Preview.Interfaces, 2)
}
func TestValidateCard_V10(t *testing.T) {
result := a2a.ValidateCard(readFixture(t, "a2a_v10_card.json"))
assert.True(t, result.Valid)
assert.Equal(t, "1.0", result.SpecVersion)
assert.Empty(t, result.Errors)
assert.NotNil(t, result.Preview)
assert.Equal(t, "Translation Agent v1.0", result.Preview.DisplayName)
assert.Equal(t, 3, result.Preview.ExtensionsCount)
assert.Contains(t, result.Preview.SecuritySchemes, "oauth2")
assert.Contains(t, result.Preview.SecuritySchemes, "mtls")
}
func TestValidateCard_Legacy(t *testing.T) {
result := a2a.ValidateCard(readFixture(t, "a2a_legacy_card.json"))
assert.True(t, result.Valid)
assert.Equal(t, "", result.SpecVersion)
assert.Empty(t, result.Errors)
assert.NotNil(t, result.Preview)
assert.Equal(t, "Legacy Agent", result.Preview.DisplayName)
assert.Equal(t, []string{"https://legacy.example.com/agent"}, result.Preview.Interfaces)
}
func TestValidateCard_Invalid(t *testing.T) {
result := a2a.ValidateCard(readFixture(t, "a2a_invalid_card.json"))
assert.False(t, result.Valid)
assert.Nil(t, result.Preview)
require.NotEmpty(t, result.Errors)
// Check specific errors
fieldErrors := map[string]bool{}
for _, e := range result.Errors {
fieldErrors[e.Field] = true
}
assert.True(t, fieldErrors["name"], "should report missing name")
assert.True(t, fieldErrors["supportedInterfaces"], "should report missing endpoints")
assert.True(t, fieldErrors["skills[0].id"], "should report missing skill id")
assert.True(t, fieldErrors["skills[0].description"], "should report missing skill description")
assert.True(t, fieldErrors["extensions[0].uri"], "should report missing extension uri")
}
func TestValidateCard_InvalidJSON(t *testing.T) {
result := a2a.ValidateCard([]byte("not json"))
assert.False(t, result.Valid)
require.Len(t, result.Errors, 1)
assert.Equal(t, "json", result.Errors[0].Field)
}
func TestValidateCard_V10_DeprecatedFields(t *testing.T) {
// v1.0 card with deprecated stateTransitionHistory
card := []byte(`{
"name": "Deprecated Test",
"description": "Tests deprecation warnings",
"version": "1.0.0",
"supportedInterfaces": [{"url": "https://example.com/a2a"}],
"capabilities": {"supportsExtendedAgentCard": true},
"stateTransitionHistory": true,
"skills": [{"id": "s1", "name": "S1", "description": "Skill 1"}]
}`)
result := a2a.ValidateCard(card)
assert.True(t, result.Valid)
assert.Equal(t, "1.0", result.SpecVersion)
assert.NotEmpty(t, result.Warnings, "should have deprecation warning for stateTransitionHistory")
}
- [ ] Step 2: Run tests to verify they fail
Run: go test ./plugins/parsers/a2a/ -run TestValidateCard -v
Expected: compilation error — a2a.ValidateCard undefined
- [ ] Step 3: Implement ValidateCard
// plugins/parsers/a2a/validation.go
package a2a
import (
"encoding/json"
"fmt"
)
// ValidationError represents a single validation error with field path.
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidationPreview is a summary of a successfully parsed card.
type ValidationPreview struct {
DisplayName string `json:"display_name"`
Description string `json:"description"`
Protocol string `json:"protocol"`
SpecVersion string `json:"spec_version"`
SkillsCount int `json:"skills_count"`
ExtensionsCount int `json:"extensions_count"`
SecuritySchemes []string `json:"security_schemes"`
Interfaces []string `json:"interfaces"`
}
// ValidationResult is the structured output of card validation.
type ValidationResult struct {
Valid bool `json:"valid"`
SpecVersion string `json:"spec_version"`
Errors []ValidationError `json:"errors"`
Warnings []string `json:"warnings"`
Preview *ValidationPreview `json:"preview,omitempty"`
}
// fullCard is the extended JSON structure for parsing all A2A spec fields.
type fullCard struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Version string `json:"version"`
Provider *a2aProvider `json:"provider,omitempty"`
Skills []fullSkill `json:"skills,omitempty"`
SupportedInterfaces []fullInterface `json:"supportedInterfaces,omitempty"`
SecuritySchemes []fullSecurity `json:"securitySchemes,omitempty"`
Signatures []fullSignature `json:"signatures,omitempty"`
SupportsExtendedAgentCard *bool `json:"supportsExtendedAgentCard,omitempty"`
Capabilities *capabilities `json:"capabilities,omitempty"`
StateTransitionHistory *bool `json:"stateTransitionHistory,omitempty"`
}
type capabilities struct {
Extensions []fullExtension `json:"extensions,omitempty"`
SupportsExtendedAgentCard *bool `json:"supportsExtendedAgentCard,omitempty"`
}
type fullSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
InputModes []string `json:"inputModes,omitempty"`
OutputModes []string `json:"outputModes,omitempty"`
}
type fullExtension struct {
URI string `json:"uri"`
Required bool `json:"required"`
}
type fullInterface struct {
URL string `json:"url"`
Binding string `json:"binding,omitempty"`
}
type fullSecurity struct {
Type string `json:"type"`
Method string `json:"method,omitempty"`
Name string `json:"name,omitempty"`
}
type fullSignature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"keyId,omitempty"`
}
// ValidateCard validates a raw JSON agent card and returns structured results.
func ValidateCard(raw []byte) ValidationResult {
var card fullCard
if err := json.Unmarshal(raw, &card); err != nil {
return ValidationResult{
Valid: false,
Errors: []ValidationError{{Field: "json", Message: fmt.Sprintf("invalid JSON: %v", err)}},
}
}
var errs []ValidationError
var warnings []string
// Detect spec version.
specVersion := detectSpecVersion(&card)
// Required fields.
if card.Name == "" {
errs = append(errs, ValidationError{Field: "name", Message: "required field is missing"})
}
if card.Description == "" {
errs = append(errs, ValidationError{Field: "description", Message: "required field is missing"})
}
if card.Version == "" {
errs = append(errs, ValidationError{Field: "version", Message: "required field is missing"})
}
// Endpoint: supportedInterfaces or legacy url.
if len(card.SupportedInterfaces) == 0 && card.URL == "" {
errs = append(errs, ValidationError{Field: "supportedInterfaces", Message: "at least one endpoint is required (supportedInterfaces or url)"})
}
// Validate skills.
for i, s := range card.Skills {
if s.ID == "" {
errs = append(errs, ValidationError{Field: fmt.Sprintf("skills[%d].id", i), Message: "required field is missing"})
}
if s.Name == "" {
errs = append(errs, ValidationError{Field: fmt.Sprintf("skills[%d].name", i), Message: "required field is missing"})
}
if s.Description == "" {
errs = append(errs, ValidationError{Field: fmt.Sprintf("skills[%d].description", i), Message: "required field is missing"})
}
}
// Validate extensions.
if card.Capabilities != nil {
for i, ext := range card.Capabilities.Extensions {
if ext.URI == "" {
errs = append(errs, ValidationError{Field: fmt.Sprintf("extensions[%d].uri", i), Message: "required field is missing"})
}
}
}
// Deprecation warnings.
if card.StateTransitionHistory != nil {
warnings = append(warnings, "stateTransitionHistory field removed in v1.0, ignored")
}
if len(errs) > 0 {
return ValidationResult{
Valid: false,
SpecVersion: specVersion,
Errors: errs,
Warnings: warnings,
}
}
// Build preview.
interfaces := make([]string, 0)
for _, iface := range card.SupportedInterfaces {
interfaces = append(interfaces, iface.URL)
}
if len(interfaces) == 0 && card.URL != "" {
interfaces = append(interfaces, card.URL)
}
secSchemes := make([]string, 0)
for _, s := range card.SecuritySchemes {
secSchemes = append(secSchemes, s.Type)
}
extCount := 0
if card.Capabilities != nil {
extCount = len(card.Capabilities.Extensions)
}
return ValidationResult{
Valid: true,
SpecVersion: specVersion,
Errors: []ValidationError{},
Warnings: warnings,
Preview: &ValidationPreview{
DisplayName: card.Name,
Description: card.Description,
Protocol: "a2a",
SpecVersion: specVersion,
SkillsCount: len(card.Skills),
ExtensionsCount: extCount,
SecuritySchemes: secSchemes,
Interfaces: interfaces,
},
}
}
// detectSpecVersion determines the A2A spec version based on card signals.
func detectSpecVersion(card *fullCard) string {
// v0.3: supportsExtendedAgentCard at root level.
if card.SupportsExtendedAgentCard != nil {
return "0.3"
}
// v1.0: supportsExtendedAgentCard in capabilities (not root).
if card.Capabilities != nil && card.Capabilities.SupportsExtendedAgentCard != nil {
return "1.0"
}
// No version signal detected.
return ""
}
- [ ] Step 4: Run validation tests
Run: go test ./plugins/parsers/a2a/ -run TestValidateCard -v
Expected: ALL PASS
- [ ] Step 5: Commit
git add plugins/parsers/a2a/validation.go plugins/parsers/a2a/validation_test.go
git commit -m "feat: add A2A card validation with spec version detection and structured errors"
Task 6: Enhance A2A Parser Parse() Method¶
Files:
- Modify: plugins/parsers/a2a/a2a.go
- [ ] Step 1: Write failing test for Parse() with v1.0 card
// Add to plugins/parsers/a2a/validation_test.go
import (
"github.com/PawelHaracz/agentlens/internal/model"
"github.com/PawelHaracz/agentlens/plugins/parsers/a2a"
)
func TestParse_V10Card(t *testing.T) {
p := a2a.New()
entry, err := p.Parse(readFixture(t, "a2a_v10_card.json"), model.SourcePush)
require.NoError(t, err)
assert.Equal(t, "Translation Agent v1.0", entry.DisplayName)
assert.Equal(t, "https://translate.example.com/a2a", entry.Endpoint)
assert.Equal(t, "1.0", entry.SpecVersion)
assert.NotEmpty(t, entry.TypedMeta)
// Check typed metadata contents.
var extCount, secCount, ifaceCount int
for _, tm := range entry.TypedMeta {
switch tm.Kind() {
case "a2a.extension":
extCount++
case "a2a.security_scheme":
secCount++
case "a2a.interface":
ifaceCount++
}
}
assert.Equal(t, 3, extCount)
assert.Equal(t, 2, secCount)
assert.Equal(t, 1, ifaceCount)
}
func TestParse_V03Card(t *testing.T) {
p := a2a.New()
entry, err := p.Parse(readFixture(t, "a2a_v03_card.json"), model.SourceConfig)
require.NoError(t, err)
assert.Equal(t, "Weather Agent v0.3", entry.DisplayName)
assert.Equal(t, "https://weather.example.com/a2a", entry.Endpoint)
assert.Equal(t, "0.3", entry.SpecVersion)
assert.Equal(t, model.SourceConfig, entry.Source)
}
func TestParse_LegacyCard(t *testing.T) {
p := a2a.New()
entry, err := p.Parse(readFixture(t, "a2a_legacy_card.json"), model.SourcePush)
require.NoError(t, err)
assert.Equal(t, "Legacy Agent", entry.DisplayName)
assert.Equal(t, "https://legacy.example.com/agent", entry.Endpoint)
assert.Equal(t, "", entry.SpecVersion)
assert.Empty(t, entry.TypedMeta)
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./plugins/parsers/a2a/ -run TestParse_V10Card -v
Expected: FAIL — entry.SpecVersion is empty, entry.TypedMeta is nil
- [ ] Step 3: Update Parse() to extract new fields
Replace the entire a2a.go content:
// Package a2a provides the A2A protocol parser plugin.
package a2a
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/PawelHaracz/agentlens/internal/kernel"
"github.com/PawelHaracz/agentlens/internal/model"
)
// Plugin implements the A2A parser plugin.
type Plugin struct {
initialized bool
}
// New creates a new A2A parser plugin.
func New() *Plugin { return &Plugin{} }
// Name returns the plugin name.
func (p *Plugin) Name() string { return "a2a-parser" }
// Version returns the plugin version.
func (p *Plugin) Version() string { return "1.0.0" }
// Type returns the plugin type.
func (p *Plugin) Type() kernel.PluginType { return kernel.PluginTypeParser }
// Protocol returns the protocol this parser handles.
func (p *Plugin) Protocol() model.Protocol { return model.ProtocolA2A }
// CardPath returns the default card path for A2A.
func (p *Plugin) CardPath() string { return "/.well-known/agent-card.json" }
// Init initializes the plugin.
func (p *Plugin) Init(k kernel.Kernel) error {
p.initialized = true
return nil
}
// Start starts the plugin (no-op for parser).
func (p *Plugin) Start(ctx context.Context) error { return nil }
// Stop stops the plugin (no-op for parser).
func (p *Plugin) Stop(ctx context.Context) error { return nil }
// Parse parses an A2A agent card JSON blob into a CatalogEntry.
func (p *Plugin) Parse(raw []byte, source model.SourceType) (*model.CatalogEntry, error) {
var card fullCard
if err := json.Unmarshal(raw, &card); err != nil {
return nil, fmt.Errorf("parsing a2a card: %w", err)
}
if card.Name == "" {
return nil, fmt.Errorf("a2a card missing required field: name")
}
// Determine endpoint: prefer first supportedInterfaces URL, fall back to url.
endpoint := card.URL
if len(card.SupportedInterfaces) > 0 {
endpoint = card.SupportedInterfaces[0].URL
}
if endpoint == "" {
return nil, fmt.Errorf("a2a card missing required field: url or supportedInterfaces")
}
// Convert skills.
skills := make([]model.Skill, 0, len(card.Skills))
for _, s := range card.Skills {
skills = append(skills, model.Skill{
Name: s.Name,
Description: s.Description,
InputModes: s.InputModes,
OutputModes: s.OutputModes,
})
}
// Build provider.
var provider model.Provider
if card.Provider != nil {
provider.Organization = card.Provider.Organization
provider.Team = card.Provider.Organization
provider.URL = card.Provider.URL
}
// Detect spec version.
specVersion := detectSpecVersion(&card)
// Build typed metadata.
var typedMeta []model.TypedMetadata
// Extensions from capabilities.
if card.Capabilities != nil {
for _, ext := range card.Capabilities.Extensions {
typedMeta = append(typedMeta, &model.A2AExtension{
URI: ext.URI,
Required: ext.Required,
})
}
}
// Security schemes.
for _, sec := range card.SecuritySchemes {
typedMeta = append(typedMeta, &model.A2ASecurityScheme{
Type: sec.Type,
Method: sec.Method,
Name: sec.Name,
})
}
// Supported interfaces.
for _, iface := range card.SupportedInterfaces {
typedMeta = append(typedMeta, &model.A2AInterface{
URL: iface.URL,
Binding: iface.Binding,
})
}
// Signatures.
for _, sig := range card.Signatures {
typedMeta = append(typedMeta, &model.A2ASignature{
Algorithm: sig.Algorithm,
KeyID: sig.KeyID,
})
}
now := time.Now().UTC()
return &model.CatalogEntry{
DisplayName: card.Name,
Description: card.Description,
Protocol: model.ProtocolA2A,
Endpoint: endpoint,
Version: card.Version,
Status: model.StatusUnknown,
Source: source,
Provider: provider,
Skills: skills,
SpecVersion: specVersion,
TypedMeta: typedMeta,
Validity: model.Validity{LastSeen: now},
RawCard: json.RawMessage(raw),
CreatedAt: now,
UpdatedAt: now,
}, nil
}
- [ ] Step 4: Run all parser tests
Run: go test ./plugins/parsers/a2a/ -v
Expected: ALL PASS
- [ ] Step 5: Commit
git add plugins/parsers/a2a/a2a.go plugins/parsers/a2a/validation_test.go
git commit -m "feat: enhance A2A parser to extract extensions, security schemes, interfaces, and spec version"
Task 7: Validation Endpoint¶
Files:
- Create: internal/api/validate_handler.go
- Create: internal/api/validate_handler_test.go
- Modify: internal/api/router.go
- [ ] Step 1: Write failing test for validation endpoint
// internal/api/validate_handler_test.go
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/PawelHaracz/agentlens/plugins/parsers/a2a"
)
func TestValidateEndpoint_ValidCard(t *testing.T) {
router, _ := newTestRouter(t)
body, err := os.ReadFile("../../plugins/parsers/a2a/testdata/a2a_v10_card.json")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/catalog/validate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result a2a.ValidationResult
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.True(t, result.Valid)
assert.Equal(t, "1.0", result.SpecVersion)
assert.NotNil(t, result.Preview)
assert.Equal(t, "Translation Agent v1.0", result.Preview.DisplayName)
}
func TestValidateEndpoint_InvalidCard(t *testing.T) {
router, _ := newTestRouter(t)
body, err := os.ReadFile("../../plugins/parsers/a2a/testdata/a2a_invalid_card.json")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/catalog/validate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
var result a2a.ValidationResult
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.False(t, result.Valid)
assert.NotEmpty(t, result.Errors)
}
func TestValidateEndpoint_InvalidJSON(t *testing.T) {
router, _ := newTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/catalog/validate", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/api/ -run TestValidateEndpoint -v
Expected: 404 — route not registered
- [ ] Step 3: Implement validation handler
// internal/api/validate_handler.go
package api
import (
"io"
"net/http"
"github.com/PawelHaracz/agentlens/plugins/parsers/a2a"
)
// ValidateAgentCard handles POST /api/v1/catalog/validate.
func (h *Handler) ValidateAgentCard(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "failed to read request body")
return
}
defer r.Body.Close()
result := a2a.ValidateCard(raw)
if result.Valid {
JSONResponse(w, http.StatusOK, result)
} else {
JSONResponse(w, http.StatusUnprocessableEntity, result)
}
}
- [ ] Step 4: Register route in router.go
In internal/api/router.go, add the validate route next to the existing catalog routes.
In the protected catalog routes section (around line 59), add:
r.With(RequirePermission(auth.PermCatalogWrite)).Post("/catalog/validate", h.ValidateAgentCard)
In the unprotected section (around line 109), add:
r.Post("/catalog/validate", h.ValidateAgentCard)
Important: The /catalog/validate route must be registered BEFORE /catalog/{id} to avoid chi interpreting "validate" as an {id} parameter. Move it above the GET /catalog/{id} line.
- [ ] Step 5: Run validation endpoint tests
Run: go test ./internal/api/ -run TestValidateEndpoint -v
Expected: ALL PASS
- [ ] Step 6: Run all existing tests to verify no regression
Run: go test ./internal/api/ -v
Expected: ALL PASS
- [ ] Step 7: Commit
git add internal/api/validate_handler.go internal/api/validate_handler_test.go internal/api/router.go
git commit -m "feat: add POST /api/v1/catalog/validate dry-run validation endpoint"
Task 8: Frontend Types & API Client¶
Files:
- Modify: web/src/types.ts
- Modify: web/src/api.ts
- [ ] Step 1: Add TypeScript types
In web/src/types.ts, add after the ListFilter interface:
export interface TypedMeta {
kind: string
[key: string]: unknown
}
export interface A2AExtensionMeta extends TypedMeta {
kind: 'a2a.extension'
uri: string
required: boolean
}
export interface A2ASecuritySchemeMeta extends TypedMeta {
kind: 'a2a.security_scheme'
type: string
method?: string
name?: string
}
export interface A2AInterfaceMeta extends TypedMeta {
kind: 'a2a.interface'
url: string
binding?: string
}
export interface ValidationError {
field: string
message: string
}
export interface ValidationPreview {
display_name: string
description: string
protocol: string
spec_version: string
skills_count: number
extensions_count: number
security_schemes: string[]
interfaces: string[]
}
export interface ValidationResult {
valid: boolean
spec_version: string
errors: ValidationError[]
warnings: string[]
preview?: ValidationPreview
}
Add spec_version and typed_meta to the existing CatalogEntry interface:
// Add after raw_card line:
spec_version?: string
typed_meta?: TypedMeta[]
- [ ] Step 2: Add API functions
In web/src/api.ts, add after the getStats() function:
export function validateAgentCard(cardJson: string): Promise<ValidationResult> {
return request<ValidationResult>('/catalog/validate', {
method: 'POST',
body: cardJson,
})
}
export function createAgentFromCard(cardJson: string): Promise<CatalogEntry> {
return request<CatalogEntry>('/catalog', {
method: 'POST',
body: cardJson,
})
}
Add ValidationResult to the import from types:
import type { CatalogEntry, ListFilter, Stats, ValidationResult } from './types'
- [ ] Step 3: Commit
git add web/src/types.ts web/src/api.ts
git commit -m "feat: add TypedMeta types and validation API client functions"
Task 9: CardPreview Component¶
Files:
- Create: web/src/components/CardPreview.tsx
- [ ] Step 1: Create the reusable CardPreview component
// web/src/components/CardPreview.tsx
import type { ValidationPreview, TypedMeta } from '../types'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
interface CardPreviewProps {
preview: ValidationPreview
typedMeta?: TypedMeta[]
}
export default function CardPreview({ preview, typedMeta }: CardPreviewProps) {
const extensions = typedMeta?.filter(m => m.kind === 'a2a.extension') ?? []
const securitySchemes = typedMeta?.filter(m => m.kind === 'a2a.security_scheme') ?? []
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{preview.display_name}</h3>
{preview.description && (
<p className="text-sm text-muted-foreground mt-1">{preview.description}</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{preview.protocol.toUpperCase()}</Badge>
{preview.spec_version && (
<Badge variant="outline">v{preview.spec_version}</Badge>
)}
<Badge variant="secondary">{preview.skills_count} skill{preview.skills_count !== 1 ? 's' : ''}</Badge>
</div>
{preview.interfaces.length > 0 && (
<>
<Separator />
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Interfaces</p>
<div className="space-y-1">
{preview.interfaces.map((url, i) => (
<p key={i} className="text-sm font-mono break-all">{url}</p>
))}
</div>
</div>
</>
)}
{extensions.length > 0 && (
<>
<Separator />
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Extensions ({extensions.length})
</p>
<div className="space-y-1">
{extensions.map((ext, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="font-mono break-all">{(ext as { uri: string }).uri}</span>
<Badge variant={(ext as { required: boolean }).required ? 'destructive' : 'secondary'} className="text-xs">
{(ext as { required: boolean }).required ? 'Required' : 'Optional'}
</Badge>
</div>
))}
</div>
</div>
</>
)}
{(securitySchemes.length > 0 || preview.security_schemes.length > 0) && (
<>
<Separator />
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Security</p>
<div className="flex flex-wrap gap-1">
{preview.security_schemes.map((scheme, i) => (
<Badge key={i} variant="outline">{scheme}</Badge>
))}
</div>
</div>
</>
)}
</div>
)
}
- [ ] Step 2: Commit
git add web/src/components/CardPreview.tsx
git commit -m "feat: add reusable CardPreview component for agent card display"
Task 10: RegisterAgentDialog Component¶
Files:
- Create: web/src/components/RegisterAgentDialog.tsx
- Modify: web/src/components/CatalogList.tsx
- [ ] Step 1: Create the RegisterAgentDialog
// web/src/components/RegisterAgentDialog.tsx
import { useState, useCallback } from 'react'
import { validateAgentCard, createAgentFromCard } from '../api'
import type { ValidationResult } from '../types'
import CardPreview from './CardPreview'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card } from '@/components/ui/card'
import { Plus, Upload, AlertCircle, AlertTriangle, CheckCircle } from 'lucide-react'
type Step = 'input' | 'validation' | 'preview' | 'confirm'
interface RegisterAgentDialogProps {
onRegistered: () => void
}
export default function RegisterAgentDialog({ onRegistered }: RegisterAgentDialogProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState<Step>('input')
const [cardJson, setCardJson] = useState('')
const [jsonError, setJsonError] = useState<string | null>(null)
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
const [validating, setValidating] = useState(false)
const [registering, setRegistering] = useState(false)
const [registerError, setRegisterError] = useState<string | null>(null)
const reset = useCallback(() => {
setStep('input')
setCardJson('')
setJsonError(null)
setValidationResult(null)
setValidating(false)
setRegistering(false)
setRegisterError(null)
}, [])
const handleOpenChange = (v: boolean) => {
setOpen(v)
if (!v) reset()
}
const handleJsonChange = (value: string) => {
setCardJson(value)
setJsonError(null)
if (value.trim()) {
try {
JSON.parse(value)
} catch {
setJsonError('Invalid JSON syntax')
}
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = reader.result as string
handleJsonChange(text)
}
reader.readAsText(file)
}
const handleValidate = async () => {
if (jsonError || !cardJson.trim()) return
setValidating(true)
try {
const result = await validateAgentCard(cardJson)
setValidationResult(result)
if (result.valid && result.errors.length === 0 && result.warnings.length === 0) {
setStep('preview')
} else {
setStep('validation')
}
} catch (e) {
setValidationResult({
valid: false,
spec_version: '',
errors: [{ field: 'request', message: e instanceof Error ? e.message : 'Validation failed' }],
warnings: [],
})
setStep('validation')
} finally {
setValidating(false)
}
}
const handleRegister = async () => {
setRegistering(true)
setRegisterError(null)
try {
await createAgentFromCard(cardJson)
setOpen(false)
reset()
onRegistered()
} catch (e) {
setRegisterError(e instanceof Error ? e.message : 'Registration failed')
} finally {
setRegistering(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Register Agent
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register Agent</DialogTitle>
</DialogHeader>
{step === 'input' && (
<div className="space-y-4">
<Tabs defaultValue="paste">
<TabsList>
<TabsTrigger value="paste">Paste JSON</TabsTrigger>
<TabsTrigger value="upload">Upload File</TabsTrigger>
</TabsList>
<TabsContent value="paste">
<textarea
className="w-full h-64 font-mono text-sm border rounded-md p-3 bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={'{\n "name": "My Agent",\n "description": "...",\n "version": "1.0.0",\n "supportedInterfaces": [...],\n "skills": [...]\n}'}
value={cardJson}
onChange={e => handleJsonChange(e.target.value)}
/>
</TabsContent>
<TabsContent value="upload">
<div className="border-2 border-dashed rounded-md p-8 text-center">
<Upload className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground mb-2">Drop a .json file or click to browse</p>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
className="text-sm"
/>
</div>
</TabsContent>
</Tabs>
{jsonError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{jsonError}
</p>
)}
<div className="flex justify-end">
<Button onClick={handleValidate} disabled={!cardJson.trim() || !!jsonError || validating}>
{validating ? 'Validating...' : 'Validate'}
</Button>
</div>
</div>
)}
{step === 'validation' && validationResult && (
<div className="space-y-4">
{validationResult.errors.length > 0 && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="font-medium text-destructive flex items-center gap-1 mb-2">
<AlertCircle className="h-4 w-4" />
Validation Errors
</p>
<ul className="text-sm text-destructive space-y-1">
{validationResult.errors.map((err, i) => (
<li key={i}><code className="font-mono">{err.field}</code>: {err.message}</li>
))}
</ul>
</Card>
)}
{validationResult.warnings.length > 0 && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10 p-4">
<p className="font-medium text-yellow-700 dark:text-yellow-400 flex items-center gap-1 mb-2">
<AlertTriangle className="h-4 w-4" />
Warnings
</p>
<ul className="text-sm text-yellow-700 dark:text-yellow-400 space-y-1">
{validationResult.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('input')}>Back to Edit</Button>
{validationResult.valid && (
<Button onClick={() => setStep('preview')}>Continue to Preview</Button>
)}
</div>
</div>
)}
{step === 'preview' && validationResult?.preview && (
<div className="space-y-4">
<Card className="border-green-500 bg-green-50 dark:bg-green-900/10 p-4">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
Card validated successfully
</p>
</Card>
<CardPreview preview={validationResult.preview} />
{registerError && (
<Card className="border-destructive bg-destructive/10 p-4 text-destructive text-sm">
{registerError}
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('input')}>Back to Edit</Button>
<Button onClick={handleRegister} disabled={registering}>
{registering ? 'Registering...' : 'Register Agent'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
- [ ] Step 2: Add Register button to CatalogList
In web/src/components/CatalogList.tsx, add the import:
import RegisterAgentDialog from './RegisterAgentDialog'
In the filter bar <div className="flex flex-col sm:flex-row gap-2 mb-4">, add the RegisterAgentDialog after the status Select (before the closing </div> of the filter bar):
<RegisterAgentDialog onRegistered={load} />
- [ ] Step 3: Build frontend to verify compilation
Run: cd web && bun run build
Expected: Build succeeds with no TypeScript errors
- [ ] Step 4: Commit
git add web/src/components/RegisterAgentDialog.tsx web/src/components/CatalogList.tsx
git commit -m "feat: add Register Agent modal with 4-step validation flow"
Task 11: EntryDetail — Extensions & Security Display¶
Files:
- Modify: web/src/components/EntryDetail.tsx
- [ ] Step 1: Add Extensions and Security sections
In web/src/components/EntryDetail.tsx:
Add imports for the new types at the top:
import type { CatalogEntry, TypedMeta } from '../types'
In the badges section (after the version badge, around line 100), add spec_version badge:
{entry.spec_version && (
<Badge variant="outline">Spec v{entry.spec_version}</Badge>
)}
After the Skills section closing </> (around line 161) and before the Raw Card section, add:
{entry.typed_meta && entry.typed_meta.filter(m => m.kind === 'a2a.extension').length > 0 && (
<>
<Separator className="my-4" />
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Extensions ({entry.typed_meta.filter(m => m.kind === 'a2a.extension').length})
</p>
<div className="space-y-1">
{entry.typed_meta.filter(m => m.kind === 'a2a.extension').map((ext, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="font-mono break-all">{(ext as { uri: string }).uri}</span>
<Badge variant={(ext as { required: boolean }).required ? 'destructive' : 'secondary'} className="text-xs">
{(ext as { required: boolean }).required ? 'Required' : 'Optional'}
</Badge>
</div>
))}
</div>
</div>
</>
)}
{entry.typed_meta && entry.typed_meta.filter(m => m.kind === 'a2a.security_scheme').length > 0 && (
<>
<Separator className="my-4" />
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Security</p>
<div className="flex flex-wrap gap-1">
{entry.typed_meta.filter(m => m.kind === 'a2a.security_scheme').map((scheme, i) => (
<Badge key={i} variant="outline">
{(scheme as { type: string }).type}
</Badge>
))}
</div>
</div>
</>
)}
- [ ] Step 2: Build frontend to verify compilation
Run: cd web && bun run build
Expected: Build succeeds
- [ ] Step 3: Commit
git add web/src/components/EntryDetail.tsx
git commit -m "feat: display extensions, security schemes, and spec version in agent detail view"
Task 12: E2E Tests¶
Files:
- Modify: e2e/tests/helpers.ts
- Modify: e2e/tests/catalog.spec.ts
- [ ] Step 1: Add validation helper to E2E helpers
In e2e/tests/helpers.ts, add after the deleteCatalogEntry function:
/** Validate an agent card via the API. Returns the validation result. */
export async function validateAgentCard(
request: APIRequestContext,
token: string,
cardJson: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> {
const res = await request.post(`${BASE}/api/v1/catalog/validate`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: cardJson,
});
return { status: res.status(), body: await res.json() };
}
- [ ] Step 2: Add validation API tests to catalog.spec.ts
Add to the test.describe('Catalog Management') block:
test('validate valid A2A v1.0 card returns 200 with preview', async ({ request }) => {
const card = JSON.stringify({
name: 'E2E Validation Agent',
description: 'Tests validation endpoint',
version: '1.0.0',
supportedInterfaces: [{ url: `http://validate-${Date.now()}.example.com/a2a`, binding: 'jsonrpc' }],
capabilities: {
extensions: [{ uri: 'urn:test:ext', required: true }],
supportsExtendedAgentCard: true,
},
securitySchemes: [{ type: 'bearer', method: 'header', name: 'Authorization' }],
skills: [{ id: 's1', name: 'Skill One', description: 'A test skill' }],
});
const { status, body } = await validateAgentCard(request, token, card);
expect(status).toBe(200);
expect(body.valid).toBe(true);
expect(body.spec_version).toBe('1.0');
expect(body.preview.display_name).toBe('E2E Validation Agent');
expect(body.preview.extensions_count).toBe(1);
expect(body.preview.security_schemes).toContain('bearer');
});
test('validate invalid card returns 422 with errors', async ({ request }) => {
const card = JSON.stringify({
description: 'Missing name and endpoints',
skills: [{ name: 'No ID' }],
});
const { status, body } = await validateAgentCard(request, token, card);
expect(status).toBe(422);
expect(body.valid).toBe(false);
expect(body.errors.length).toBeGreaterThan(0);
const fields = body.errors.map((e: { field: string }) => e.field);
expect(fields).toContain('name');
expect(fields).toContain('supportedInterfaces');
});
- [ ] Step 3: Add UI registration flow test
test('register agent via UI modal', async ({ page }) => {
await loginViaUI(page);
// Open register dialog.
await page.getByRole('button', { name: 'Register Agent' }).click();
await expect(page.getByText('Register Agent').nth(1)).toBeVisible();
// Paste valid JSON.
const card = JSON.stringify({
name: `UI Register Agent ${Date.now()}`,
description: 'Registered via UI E2E test',
version: '1.0.0',
url: `http://ui-register-${Date.now()}.example.com`,
skills: [{ id: 'echo', name: 'Echo', description: 'Echoes input' }],
}, null, 2);
const textarea = page.locator('textarea');
await textarea.fill(card);
// Validate.
await page.getByRole('button', { name: 'Validate' }).click();
// Should advance to preview (or show preview).
await expect(page.getByText('Card validated successfully')).toBeVisible({ timeout: 10_000 });
// Register.
await page.getByRole('button', { name: 'Register Agent' }).click();
// Should close dialog and refresh catalog.
await expect(page.getByText('UI Register Agent')).toBeVisible({ timeout: 10_000 });
});
- [ ] Step 4: Commit
git add e2e/tests/helpers.ts e2e/tests/catalog.spec.ts
git commit -m "test: add E2E tests for validation endpoint and UI registration flow"
Task 13: Build, Run All Tests, Fix Issues¶
- [ ] Step 1: Run Go tests
Run: go test ./... -v
Expected: ALL PASS
- [ ] Step 2: Build Go binary
Run: go build ./cmd/agentlens/
Expected: Compiles without errors
- [ ] Step 3: Build frontend
Run: cd web && bun run build
Expected: Build succeeds
- [ ] Step 4: Fix any failures found in steps 1-3
Address each failure individually: read the error, identify root cause, fix.
- [ ] Step 5: Final commit if any fixes were needed
git add -A
git commit -m "fix: address test/build issues from integration"