diff --git a/docs/guide/a2a.md b/docs/guide/a2a.md index 5b6187bba..c6102d447 100644 --- a/docs/guide/a2a.md +++ b/docs/guide/a2a.md @@ -173,3 +173,13 @@ a2a: Outgoing calls automatically attach the `A2A-Extensions` header. Before sending, the client checks the remote agent card: if an extension is marked `required` there but not activated here, the call **fails fast** with a clear error instead of violating the remote agent's contract. The canonical well-known path `/.well-known/agent-card.json` is served in addition to `/.well-known/agent.json` and `/.well-known/a2a.json`. + +## A2A v1.0 compatibility + +ggcode stays interoperable with both 0.2.x/0.3.x peers and A2A v1.0 (2026-03) agents: + +**Task state enums (1.0.0 #1384, ADR-001 ProtoJSON).** The wire accepts both the legacy lowercase names (`working`, `input-required`) and the v1.0 ProtoJSON enum names (`TASK_STATE_WORKING`, `TASK_STATE_INPUT_REQUIRED`), plus historical spellings (`cancelled`, `input_required`). Incoming values normalize to the canonical constants, so terminal-state detection works regardless of the remote encoding. Outgoing states stay lowercase - the SDK's backwards-compat allowance (1.0.0 #1401). + +**Well-known URI fallback (0.3.0 rename).** `Discover` probes `/.well-known/agent-card.json` first, falling back to `/.well-known/agent.json` only when the path returns 404/405 or the response is not a usable card. Errors on a real card (bad signature, tampering) surface as-is instead of being masked by a retry. + +**`application/a2a+json` (1.0.1 #1753).** Servers advertise `protocolVersion` in the agent card and prefer the v1.0 media type: requests that arrive with (or accept) `a2a+json` get `a2a+json` responses; legacy peers keep `application/json`. On the client side, a discovered card declaring a 1.x version switches request bodies to `a2a+json`; sync-error detection accepts both media types (a plain `application/json` substring check would misclassify an `a2a+json` error response as SSE). diff --git a/internal/a2a/client.go b/internal/a2a/client.go index 8e3574fd8..aca5286fd 100644 --- a/internal/a2a/client.go +++ b/internal/a2a/client.go @@ -6,6 +6,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -203,12 +204,41 @@ func NewClient(baseURL, apiKey string, opts ...ClientOption) *Client { } // Discover fetches and caches the remote agent's Agent Card. +// +// A2A 0.3.0 renamed the well-known URI from agent.json to agent-card.json +// and 0.3.x/v1.0 servers may serve only the new path, while legacy ggcode +// peers serve only the old one. Fallback is limited to 404/405 (path +// absent) - a card that exists but fails signature verification, decoding, +// or transport must surface its real error instead of masking it with a +// second request. func (c *Client) Discover(ctx context.Context) (*AgentCard, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - c.baseURL+"/.well-known/agent.json", nil) + card, err := c.fetchCard(ctx, "/.well-known/agent-card.json") + if err != nil { + if !errors.Is(err, errCardWellKnownNotFound) && !errors.Is(err, errCardInvalid) { + return nil, err + } + card, err = c.fetchCard(ctx, "/.well-known/agent.json") + } + return card, err +} + +// errCardWellKnownNotFound marks a well-known card URI that the server does +// not serve (404/405), the only condition that triggers path fallback. +var errCardWellKnownNotFound = errors.New("a2a discover: well-known card path not served") + +// errCardInvalid marks a 200 response that is not a usable agent card. The +// spec requires name and url; without this check a redirect landing on an +// unrelated JSON endpoint would silently produce an empty card. +var errCardInvalid = errors.New("a2a discover: response is not a valid agent card") + +// fetchCard fetches the card from one well-known path. +func (c *Client) fetchCard(ctx context.Context, path string) (*AgentCard, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return nil, fmt.Errorf("a2a discover: %w", err) } + // Advertise both A2A JSON media types (1.0.1 prefers a2a+json). + req.Header.Set("Accept", acceptHeader) resp, err := c.httpClient.Do(req) if err != nil { @@ -216,6 +246,10 @@ func (c *Client) Discover(ctx context.Context) (*AgentCard, error) { } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { + return nil, errCardWellKnownNotFound + } + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("a2a discover: HTTP %d", resp.StatusCode) } @@ -229,6 +263,9 @@ func (c *Client) Discover(ctx context.Context) (*AgentCard, error) { if err := json.Unmarshal(body, &card); err != nil { return nil, fmt.Errorf("a2a discover: decode: %w", err) } + if card.Name == "" && card.URL == "" { + return nil, errCardInvalid + } // A2A §8.4: refuse a card whose JWS signature fails to verify. if len(card.Signatures) > 0 { @@ -520,7 +557,8 @@ func (c *Client) SendMessageStream(ctx context.Context, skill, text string) (<-c if err != nil { return nil, fmt.Errorf("a2a stream: %w", err) } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", c.requestContentType()) + req.Header.Set("Accept", acceptHeader) if err := c.applyExtensions(req); err != nil { return nil, fmt.Errorf("a2a stream: %w", err) } @@ -535,7 +573,7 @@ func (c *Client) SendMessageStream(ctx context.Context, skill, text string) (<-c // server writes errors as HTTP 200 + application/json before SSE // headers are set) — parse that instead of feeding the JSON body to // the SSE decoder (which would yield a silent empty stream + nil error). - if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "application/json") { + if ct := resp.Header.Get("Content-Type"); isJSONMedia(ct) { respBody, _ := util.ReadAll(resp.Body, util.ReadLimitGeneral) resp.Body.Close() var rpcResp JSONRPCResponse @@ -677,7 +715,8 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC if err != nil { return nil, fmt.Errorf("a2a resubscribe: %w", err) } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", c.requestContentType()) + req.Header.Set("Accept", acceptHeader) if err := c.applyExtensions(req); err != nil { return nil, fmt.Errorf("a2a resubscribe: %w", err) } @@ -690,7 +729,7 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC // Check Content-Type: if JSON (not SSE), this is a sync error response. ct := resp.Header.Get("Content-Type") - if strings.Contains(ct, "application/json") { + if isJSONMedia(ct) { defer resp.Body.Close() respBody, _ := util.ReadAll(resp.Body, util.ReadLimitGeneral) var rpcResp JSONRPCResponse @@ -720,6 +759,30 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC // Internal helpers // --------------------------------------------------------------------------- +// acceptHeader advertises both A2A JSON media types. 1.0.1 prefers +// application/a2a+json in the HTTP binding; servers may still answer with +// plain application/json (0.2.x/0.3.x peers always do). +const acceptHeader = "application/a2a+json, application/json" + +// requestContentType returns the media type for JSON-RPC POST bodies. Peers +// whose card declares a 1.x protocolVersion receive the v1.0 preferred type; +// legacy peers keep application/json so older strict servers are unaffected. +func (c *Client) requestContentType() string { + if card := c.Card(); card != nil && strings.HasPrefix(card.ProtocolReversion, "1") { + return "application/a2a+json" + } + return "application/json" +} + +// isJSONMedia reports whether a Content-Type is one of the A2A JSON media +// types. "application/a2a+json" does NOT contain the substring +// "application/json", so a plain Contains check misclassified a v1.0 +// server's sync error response as SSE and fed JSON into the SSE decoder. +func isJSONMedia(ct string) bool { + ct = strings.ToLower(ct) + return strings.Contains(ct, "application/json") || strings.Contains(ct, "a2a+json") +} + func (c *Client) rpc(ctx context.Context, method string, params interface{}, result interface{}) error { paramsJSON, err := json.Marshal(params) if err != nil { @@ -740,7 +803,8 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res if err != nil { return fmt.Errorf("a2a %s: %w", method, err) } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", c.requestContentType()) + req.Header.Set("Accept", acceptHeader) if err := c.applyExtensions(req); err != nil { return fmt.Errorf("a2a %s: %w", method, err) } @@ -756,7 +820,6 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res if err != nil { return fmt.Errorf("a2a %s: read: %w", method, err) } - if resp.StatusCode != http.StatusOK { var rpcResp JSONRPCResponse if err := json.Unmarshal(respBody, &rpcResp); err == nil && rpcResp.Error != nil { @@ -773,7 +836,6 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res if err := json.Unmarshal(respBody, &rpcResp); err != nil { return fmt.Errorf("a2a %s: decode: %w", method, err) } - if rpcResp.Error != nil { return rpcResp.Error } diff --git a/internal/a2a/server.go b/internal/a2a/server.go index 4c6943798..96de1863f 100644 --- a/internal/a2a/server.go +++ b/internal/a2a/server.go @@ -106,9 +106,10 @@ func NewServer(cfg ServerConfig, handler *TaskHandler) *Server { // Build Agent Card. meta := handler.WorkspaceMetadata() s.card = AgentCard{ - Name: "ggcode", - Description: fmt.Sprintf("AI coding agent for %s", meta.ProjName), - Version: "1.0.0", + Name: "ggcode", + Description: fmt.Sprintf("AI coding agent for %s", meta.ProjName), + Version: "1.0.0", + ProtocolReversion: A2AProtocolVersion, Provider: &AgentProvider{ URL: "https://github.com/topcheer/ggcode", Organization: "topcheer", @@ -322,7 +323,7 @@ func (s *Server) handleAgentCard(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", jsonRPCContentType(w)) // #565 C: copy under read lock — setters can run concurrently (hot config). s.cardMu.RLock() cardCopy := s.card @@ -342,6 +343,14 @@ func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) { return } + // Response media-type negotiation (A2A 1.0.1 "prefer application/a2a+json" + // in the HTTP binding): remember what the caller speaks so every + // writeRPCResult/writeRPCError below emits the matching type. Requests + // that don't signal a2a+json keep the legacy application/json responses. + if prefersA2AJSON(r) { + w = &rpcWriter{ResponseWriter: w, respCT: "application/a2a+json"} + } + // Required-extension gate (A2A v1.0 "Required Extensions"): a client // that has not activated every required extension cannot comply with the // agent's request contract, so reject with the UnsupportedOperation @@ -1211,8 +1220,45 @@ func writeTaskResultOrNotFound(w http.ResponseWriter, id json.RawMessage, h *Tas writeRPCError(w, id, ErrTaskNotFound) } +// rpcWriter carries the negotiated JSON response media type alongside the +// underlying writer (set once per request in handleRPC). +type rpcWriter struct { + http.ResponseWriter + respCT string +} + +// Flush forwards to the underlying writer so message/stream handlers that +// type-assert http.Flusher keep working through the wrapper (without this, +// SSE degrades to a synchronous JSON response). +func (rw *rpcWriter) Flush() { + if f, ok := rw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// jsonRPCContentType resolves the response media type for w. Non-rpcWriter +// writers (direct callers, tests, wrapped SSE paths) default to the legacy +// application/json. +func jsonRPCContentType(w http.ResponseWriter) string { + if rw, ok := w.(*rpcWriter); ok && rw.respCT != "" { + return rw.respCT + } + return "application/json" +} + +// prefersA2AJSON reports whether the request signals the v1.0 a2a+json media +// type in its Content-Type or Accept header. +func prefersA2AJSON(r *http.Request) bool { + for _, h := range []string{"Content-Type", "Accept"} { + if v := strings.ToLower(r.Header.Get(h)); strings.Contains(v, "a2a+json") { + return true + } + } + return false +} + func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{}) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", jsonRPCContentType(w)) json.NewEncoder(w).Encode(JSONRPCResponse{ JSONRPC: "2.0", ID: normalizeResponseID(id), @@ -1221,7 +1267,7 @@ func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{ } func writeRPCError(w http.ResponseWriter, id json.RawMessage, rpcErr *JSONRPCError) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", jsonRPCContentType(w)) json.NewEncoder(w).Encode(JSONRPCResponse{ JSONRPC: "2.0", // #565 E: JSON-RPC 2.0 requires the id member to be present (null diff --git a/internal/a2a/types.go b/internal/a2a/types.go index ccacb40b2..155e1ae70 100644 --- a/internal/a2a/types.go +++ b/internal/a2a/types.go @@ -67,10 +67,14 @@ var ( // AgentCard describes an agent's identity, capabilities, and skills. // Served at GET /.well-known/agent.json type AgentCard struct { - Name string `json:"name"` - Description string `json:"description"` - URL string `json:"url"` - Version string `json:"version,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Version string `json:"version,omitempty"` + // ProtocolReversion is the A2A protocol version this card speaks (spec + // field "protocolVersion", required since 0.2.5). Clients use it to + // select v1.0 media types and encodings. + ProtocolReversion string `json:"protocolVersion,omitempty"` Provider *AgentProvider `json:"provider,omitempty"` Capabilities AgentCapabilities `json:"capabilities"` SecuritySchemes map[string]Security `json:"securitySchemes,omitempty"` // deprecated, kept for compat @@ -315,6 +319,94 @@ const ( TaskStateAuthRequired TaskState = "auth-required" ) +// A2A v1.0 (2026-03, ADR-001 ProtoJSON) enum value names. JSON-RPC 0.2.x/0.3.x +// peers serialize states as the legacy lowercase names above; v1.0 transports +// (gRPC / transcoded HTTP) serialize the proto enum value names instead. +// See the 1.0.0 changelog entry "Align enum format with ADR-001 ProtoJSON +// specification" (a2aproject/A2A #1384). +const ( + TaskStateV1Submitted = "TASK_STATE_SUBMITTED" + TaskStateV1Working = "TASK_STATE_WORKING" + TaskStateV1InputRequired = "TASK_STATE_INPUT_REQUIRED" + TaskStateV1Completed = "TASK_STATE_COMPLETED" + TaskStateV1Canceled = "TASK_STATE_CANCELED" + TaskStateV1Failed = "TASK_STATE_FAILED" + TaskStateV1Rejected = "TASK_STATE_REJECTED" + TaskStateV1AuthRequired = "TASK_STATE_AUTH_REQUIRED" +) + +// taskStateAliases maps every known wire encoding of a task state - legacy +// lowercase, v1.0 ProtoJSON names, the pre-#1283 British spelling, and the +// underscored variants seen from SDK transcoders - to the canonical constant. +var taskStateAliases = map[string]TaskState{ + // legacy (JSON-RPC binding, 0.2.x/0.3.x) + "submitted": TaskStateSubmitted, + "working": TaskStateWorking, + "input-required": TaskStateInputRequired, + "completed": TaskStateCompleted, + "canceled": TaskStateCanceled, + "failed": TaskStateFailed, + "rejected": TaskStateRejected, + "auth-required": TaskStateAuthRequired, + // historical spellings tolerated for interop + "cancelled": TaskStateCanceled, + "input_required": TaskStateInputRequired, + "auth_required": TaskStateAuthRequired, + // v1.0 ProtoJSON names (ADR-001) + TaskStateV1Submitted: TaskStateSubmitted, + TaskStateV1Working: TaskStateWorking, + TaskStateV1InputRequired: TaskStateInputRequired, + TaskStateV1Completed: TaskStateCompleted, + TaskStateV1Canceled: TaskStateCanceled, + TaskStateV1Failed: TaskStateFailed, + TaskStateV1Rejected: TaskStateRejected, + TaskStateV1AuthRequired: TaskStateAuthRequired, +} + +// taskStateV1Names is the canonical → v1.0 ProtoJSON name mapping. +var taskStateV1Names = map[TaskState]string{ + TaskStateSubmitted: TaskStateV1Submitted, + TaskStateWorking: TaskStateV1Working, + TaskStateInputRequired: TaskStateV1InputRequired, + TaskStateCompleted: TaskStateV1Completed, + TaskStateCanceled: TaskStateV1Canceled, + TaskStateFailed: TaskStateV1Failed, + TaskStateRejected: TaskStateV1Rejected, + TaskStateAuthRequired: TaskStateV1AuthRequired, +} + +// UnmarshalJSON accepts both the legacy lowercase state names and the A2A +// v1.0 ProtoJSON enum names (TASK_STATE_*) on the wire, normalizing to the +// canonical constants. Without this, a v1.0 remote agent reporting +// "TASK_STATE_COMPLETED" decoded as an unknown state whose IsTerminal() is +// false - the caller waited forever on a task that had already finished. +func (s *TaskState) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if mapped, ok := taskStateAliases[raw]; ok { + *s = mapped + return nil + } + *s = TaskState(raw) // unknown states preserved verbatim (forward compat) + return nil +} + +// MarshalJSON emits the legacy lowercase name so ggcode peers running older +// builds keep round-tripping unchanged (SDK backwards-compat allowance, +// 1.0.0 changelog #1401). Use V1Name when speaking to a v1.0 transport. +func (s TaskState) MarshalJSON() ([]byte, error) { return json.Marshal(string(s)) } + +// V1Name returns the A2A v1.0 ProtoJSON enum name for the state +// ("working" → "TASK_STATE_WORKING"); unknown states pass through. +func (s TaskState) V1Name() string { + if n, ok := taskStateV1Names[s]; ok { + return n + } + return string(s) +} + // IsTerminal returns true for states that cannot transition further. // #1107: TaskStateAuthRequired is NOT terminal per the A2A spec - listing // it here froze the task forever (done closed, transitions blocked, cancel diff --git a/internal/a2a/v1_compat_test.go b/internal/a2a/v1_compat_test.go new file mode 100644 index 000000000..d5e9c4693 --- /dev/null +++ b/internal/a2a/v1_compat_test.go @@ -0,0 +1,255 @@ +package a2a + +// A2A v1.0 compatibility tests (sa-71): ProtoJSON enum decoding (1.0.0 +// changelog #1384), legacy well-known URI fallback (0.3.0 rename), and +// application/a2a+json media-type preference (1.0.1 #1753). + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTaskStateV1Unmarshal(t *testing.T) { + cases := []struct { + wire string + want TaskState + terminal bool + }{ + {`"TASK_STATE_COMPLETED"`, TaskStateCompleted, true}, + {`"TASK_STATE_FAILED"`, TaskStateFailed, true}, + {`"TASK_STATE_CANCELED"`, TaskStateCanceled, true}, + {`"TASK_STATE_REJECTED"`, TaskStateRejected, true}, + {`"TASK_STATE_WORKING"`, TaskStateWorking, false}, + {`"TASK_STATE_SUBMITTED"`, TaskStateSubmitted, false}, + {`"TASK_STATE_INPUT_REQUIRED"`, TaskStateInputRequired, false}, + {`"TASK_STATE_AUTH_REQUIRED"`, TaskStateAuthRequired, false}, + // legacy lowercase (0.2.x/0.3.x JSON-RPC peers) + {`"completed"`, TaskStateCompleted, true}, + {`"working"`, TaskStateWorking, false}, + // historical spellings tolerated for interop + {`"cancelled"`, TaskStateCanceled, true}, + {`"input_required"`, TaskStateInputRequired, false}, + {`"auth_required"`, TaskStateAuthRequired, false}, + // unknown states preserved verbatim (forward compat) + {`"brand-new-state"`, TaskState("brand-new-state"), false}, + } + for _, tc := range cases { + var s TaskState + if err := json.Unmarshal([]byte(tc.wire), &s); err != nil { + t.Fatalf("unmarshal %s: %v", tc.wire, err) + } + if s != tc.want { + t.Errorf("wire %s: got %q, want %q", tc.wire, s, tc.want) + } + if got := s.IsTerminal(); got != tc.terminal { + t.Errorf("wire %s: IsTerminal()=%v, want %v", tc.wire, got, tc.terminal) + } + } +} + +// The motivating bug: a v1.0 remote agent reporting TASK_STATE_COMPLETED +// decoded as an unknown state whose IsTerminal() is false, so the caller +// waited forever on a finished task. +func TestTaskStatusV1DecodeTerminal(t *testing.T) { + var st TaskStatus + wire := []byte(`{"state":"TASK_STATE_COMPLETED","timestamp":"2026-01-01T00:00:00Z"}`) + if err := json.Unmarshal(wire, &st); err != nil { + t.Fatalf("unmarshal status: %v", err) + } + if !st.IsTerminal() { + t.Fatalf("v1 completed state must be terminal, got state=%q", st.State) + } +} + +// MarshalJSON keeps emitting the legacy lowercase name so older ggcode +// peers round-trip unchanged (1.0.0 changelog #1401 compat allowance). +func TestTaskStateMarshalLegacy(t *testing.T) { + b, err := json.Marshal(TaskStateCompleted) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != `"completed"` { + t.Fatalf("marshal: got %s, want \"completed\"", b) + } +} + +func TestTaskStateV1Name(t *testing.T) { + cases := []struct { + in TaskState + want string + }{ + {TaskStateWorking, "TASK_STATE_WORKING"}, + {TaskStateCompleted, "TASK_STATE_COMPLETED"}, + {TaskStateInputRequired, "TASK_STATE_INPUT_REQUIRED"}, + {TaskState("brand-new-state"), "brand-new-state"}, + } + for _, tc := range cases { + if got := tc.in.V1Name(); got != tc.want { + t.Errorf("V1Name(%q)=%q, want %q", tc.in, got, tc.want) + } + } +} + +func TestIsJSONMedia(t *testing.T) { + cases := []struct { + ct string + want bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"application/a2a+json", true}, + {"application/a2a+json; charset=utf-8", true}, + {"APPLICATION/A2A+JSON", true}, + {"text/event-stream", false}, + {"", false}, + } + for _, tc := range cases { + if got := isJSONMedia(tc.ct); got != tc.want { + t.Errorf("isJSONMedia(%q)=%v, want %v", tc.ct, got, tc.want) + } + } +} + +func TestRequestContentType(t *testing.T) { + // No card discovered yet: legacy default. + c := NewClient("http://127.0.0.1:1", "k") + if got := c.requestContentType(); got != "application/json" { + t.Fatalf("no card: got %q", got) + } + // v1.0 card: a2a+json. + c.card.Store(&AgentCard{ProtocolReversion: "1.0"}) + if got := c.requestContentType(); got != "application/a2a+json" { + t.Fatalf("v1 card: got %q", got) + } + // Legacy card (0.2.x, no protocolVersion): application/json. + c.card.Store(&AgentCard{}) + if got := c.requestContentType(); got != "application/json" { + t.Fatalf("legacy card: got %q", got) + } +} + +// Discover must fall back to the legacy agent.json well-known URI when a +// v1 server serves only agent-card.json (0.3.0 rename) — and vice versa. +func TestDiscoverWellKnownFallback(t *testing.T) { + v1Card := `{"name":"v1","protocolVersion":"1.0","url":"http://x"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/agent-card.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/a2a+json") + w.Write([]byte(v1Card)) + })) + defer srv.Close() + + c := NewClient(srv.URL, "k") + card, err := c.Discover(context.Background()) + if err != nil { + t.Fatalf("discover (card.json only): %v", err) + } + if card.ProtocolReversion != "1.0" { + t.Fatalf("card protocolVersion=%q", card.ProtocolReversion) + } + if got := c.requestContentType(); got != "application/a2a+json" { + t.Fatalf("after v1 discover, request CT=%q", got) + } + + legacyCard := `{"name":"legacy","url":"http://x"}` + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/agent.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(legacyCard)) + })) + defer srv2.Close() + + c2 := NewClient(srv2.URL, "k") + if _, err := c2.Discover(context.Background()); err != nil { + t.Fatalf("discover (agent.json only): %v", err) + } + if got := c2.requestContentType(); got != "application/json" { + t.Fatalf("after legacy discover, request CT=%q", got) + } +} + +func TestPrefersA2AJSON(t *testing.T) { + cases := []struct { + ct, accept string + want bool + }{ + {"application/a2a+json", "", true}, + {"application/json", "application/a2a+json, application/json", true}, + {"application/json", "application/json", false}, + {"", "", false}, + } + for i, tc := range cases { + r := httptest.NewRequest(http.MethodPost, "/", nil) + if tc.ct != "" { + r.Header.Set("Content-Type", tc.ct) + } + if tc.accept != "" { + r.Header.Set("Accept", tc.accept) + } + if got := prefersA2AJSON(r); got != tc.want { + t.Errorf("case %d: prefersA2AJSON=%v, want %v", i, got, tc.want) + } + } +} + +func TestWriteRPCContentTypeNegotiation(t *testing.T) { + // Legacy request path: plain recorder keeps application/json. + rec := httptest.NewRecorder() + writeRPCResult(rec, json.RawMessage(`1`), map[string]string{"ok": "true"}) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("legacy result CT=%q", ct) + } + + // Negotiated request path: rpcWriter emits a2a+json. + rec2 := httptest.NewRecorder() + nw := &rpcWriter{ResponseWriter: rec2, respCT: "application/a2a+json"} + writeRPCResult(nw, json.RawMessage(`1`), map[string]string{"ok": "true"}) + if ct := rec2.Header().Get("Content-Type"); ct != "application/a2a+json" { + t.Fatalf("negotiated result CT=%q", ct) + } + if !strings.Contains(rec2.Body.String(), `"jsonrpc":"2.0"`) { + t.Fatalf("negotiated body=%q", rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + nw3 := &rpcWriter{ResponseWriter: rec3, respCT: "application/a2a+json"} + writeRPCError(nw3, json.RawMessage(`1`), ErrTaskNotFound) + if ct := rec3.Header().Get("Content-Type"); ct != "application/a2a+json" { + t.Fatalf("negotiated error CT=%q", ct) + } +} + +// The server's agent card must serialize the required protocolVersion field +// (spec-mandatory since 0.2.5) so v1 clients can negotiate media types and +// enum encodings against ggcode. +func TestServerCardSerializesProtocolVersion(t *testing.T) { + if A2AProtocolVersion != "1.0" { + t.Fatalf("A2AProtocolVersion=%q, want \"1.0\"", A2AProtocolVersion) + } + card := AgentCard{ + Name: "ggcode", + URL: "http://127.0.0.1:1", + ProtocolReversion: A2AProtocolVersion, + } + b, err := json.Marshal(card) + if err != nil { + t.Fatalf("marshal card: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal card: %v", err) + } + if string(raw["protocolVersion"]) != `"1.0"` { + t.Fatalf("wire protocolVersion=%s, want \"1.0\"", raw["protocolVersion"]) + } +}