forked from molecule-ai/molecule-core
Merge pull request #2349 from Molecule-AI/auto/issue-2345-a2a-v02-compat-clean
fix(a2a): v0.2 → v0.3 compat shim at proxy edge (#2345)
This commit is contained in:
commit
0b83faa33c
@ -486,11 +486,54 @@ func normalizeA2APayload(body []byte) ([]byte, string, *proxyA2AError) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure params.message.messageId exists (required by a2a-sdk)
|
// Ensure params.message.messageId exists (required by a2a-sdk)
|
||||||
|
// AND v0.2→v0.3 compat (#2345): when sender supplies
|
||||||
|
// params.message.content (v0.2) instead of params.message.parts
|
||||||
|
// (v0.3), wrap the content as a single text Part so the downstream
|
||||||
|
// a2a-sdk's v0.3 Pydantic validator accepts the message.
|
||||||
|
//
|
||||||
|
// Pre-fix: Design Director silently dropped briefs whose sender
|
||||||
|
// used v0.2 shape — Pydantic rejected at parse time, the rejection
|
||||||
|
// went only to logs, and the sender saw a happy 200/202.
|
||||||
|
//
|
||||||
|
// Reject loud (HTTP 400) when neither content nor parts is present;
|
||||||
|
// previously the SDK's own rejection happened post-handler-dispatch
|
||||||
|
// and was invisible to the original sender.
|
||||||
if params, ok := payload["params"].(map[string]interface{}); ok {
|
if params, ok := payload["params"].(map[string]interface{}); ok {
|
||||||
if msg, ok := params["message"].(map[string]interface{}); ok {
|
if msg, ok := params["message"].(map[string]interface{}); ok {
|
||||||
if _, hasID := msg["messageId"]; !hasID {
|
if _, hasID := msg["messageId"]; !hasID {
|
||||||
msg["messageId"] = uuid.New().String()
|
msg["messageId"] = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
_, hasParts := msg["parts"]
|
||||||
|
rawContent, hasContent := msg["content"]
|
||||||
|
if !hasParts {
|
||||||
|
if hasContent {
|
||||||
|
switch v := rawContent.(type) {
|
||||||
|
case string:
|
||||||
|
msg["parts"] = []interface{}{
|
||||||
|
map[string]interface{}{"kind": "text", "text": v},
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
msg["parts"] = v
|
||||||
|
default:
|
||||||
|
return nil, "", &proxyA2AError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Response: gin.H{
|
||||||
|
"error": "invalid params.message.content type",
|
||||||
|
"hint": "content must be a string (v0.2 compat) or omitted in favour of parts (v0.3)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(msg, "content")
|
||||||
|
} else {
|
||||||
|
return nil, "", &proxyA2AError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Response: gin.H{
|
||||||
|
"error": "params.message must contain either 'parts' (v0.3) or 'content' (v0.2 compat)",
|
||||||
|
"hint": "v0.3 example: {\"parts\":[{\"kind\":\"text\",\"text\":\"...\"}]}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -1137,7 +1138,10 @@ func TestNormalizeA2APayload_PreservesExistingMessageId(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) {
|
func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) {
|
||||||
raw := []byte(`{"params":{"message":{"role":"user"}}}`)
|
// Method extraction returns empty string when method is absent,
|
||||||
|
// regardless of message validity. Include parts: [] so the v0.2→v0.3
|
||||||
|
// compat check (#2345) doesn't reject before method extraction.
|
||||||
|
raw := []byte(`{"params":{"message":{"role":"user","parts":[]}}}`)
|
||||||
_, method, perr := normalizeA2APayload(raw)
|
_, method, perr := normalizeA2APayload(raw)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
t.Fatalf("unexpected error: %+v", perr)
|
t.Fatalf("unexpected error: %+v", perr)
|
||||||
@ -1147,6 +1151,102 @@ func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.2 → v0.3 compat shim (#2345) ---
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_ConvertsV02StringContentToParts(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"message/send","params":{"message":{"role":"user","content":"hello world"}}}`)
|
||||||
|
out, _, perr := normalizeA2APayload(raw)
|
||||||
|
if perr != nil {
|
||||||
|
t.Fatalf("unexpected error: %+v", perr)
|
||||||
|
}
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
msg := parsed["params"].(map[string]interface{})["message"].(map[string]interface{})
|
||||||
|
if _, stillHasContent := msg["content"]; stillHasContent {
|
||||||
|
t.Error("v0.2 'content' field should be removed after conversion")
|
||||||
|
}
|
||||||
|
parts, ok := msg["parts"].([]interface{})
|
||||||
|
if !ok || len(parts) != 1 {
|
||||||
|
t.Fatalf("expected 1 part, got %v", msg["parts"])
|
||||||
|
}
|
||||||
|
part := parts[0].(map[string]interface{})
|
||||||
|
if part["kind"] != "text" || part["text"] != "hello world" {
|
||||||
|
t.Errorf("expected {kind:text, text:'hello world'}, got %v", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_ConvertsV02ListContentToParts(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"message/send","params":{"message":{"role":"user","content":[{"kind":"text","text":"hi"}]}}}`)
|
||||||
|
out, _, perr := normalizeA2APayload(raw)
|
||||||
|
if perr != nil {
|
||||||
|
t.Fatalf("unexpected error: %+v", perr)
|
||||||
|
}
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
_ = json.Unmarshal(out, &parsed)
|
||||||
|
msg := parsed["params"].(map[string]interface{})["message"].(map[string]interface{})
|
||||||
|
parts, ok := msg["parts"].([]interface{})
|
||||||
|
if !ok || len(parts) != 1 {
|
||||||
|
t.Fatalf("expected list preserved as parts, got %v", msg["parts"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_PreservesV03Parts(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}`)
|
||||||
|
out, _, perr := normalizeA2APayload(raw)
|
||||||
|
if perr != nil {
|
||||||
|
t.Fatalf("unexpected error: %+v", perr)
|
||||||
|
}
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
_ = json.Unmarshal(out, &parsed)
|
||||||
|
msg := parsed["params"].(map[string]interface{})["message"].(map[string]interface{})
|
||||||
|
if _, hasContent := msg["content"]; hasContent {
|
||||||
|
t.Error("did not expect content field in v0.3-shaped payload output")
|
||||||
|
}
|
||||||
|
parts := msg["parts"].([]interface{})
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Errorf("expected 1 part preserved, got %d", len(parts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_RejectsMessageWithNeitherContentNorParts(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"message/send","params":{"message":{"role":"user","metadata":{}}}}`)
|
||||||
|
_, _, perr := normalizeA2APayload(raw)
|
||||||
|
if perr == nil {
|
||||||
|
t.Fatal("expected error for message with neither content nor parts")
|
||||||
|
}
|
||||||
|
if perr.Status != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", perr.Status)
|
||||||
|
}
|
||||||
|
errMsg, _ := perr.Response["error"].(string)
|
||||||
|
if !strings.Contains(errMsg, "parts") || !strings.Contains(errMsg, "content") {
|
||||||
|
t.Errorf("error message should mention both 'parts' and 'content', got: %q", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_RejectsContentWithUnsupportedType(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"message/send","params":{"message":{"role":"user","content":42}}}`)
|
||||||
|
_, _, perr := normalizeA2APayload(raw)
|
||||||
|
if perr == nil {
|
||||||
|
t.Fatal("expected error for non-string non-list content")
|
||||||
|
}
|
||||||
|
if perr.Status != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", perr.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeA2APayload_NoMessageNoCheck(t *testing.T) {
|
||||||
|
raw := []byte(`{"method":"tasks/list","params":{}}`)
|
||||||
|
_, method, perr := normalizeA2APayload(raw)
|
||||||
|
if perr != nil {
|
||||||
|
t.Fatalf("unexpected error on params-message-absent payload: %+v", perr)
|
||||||
|
}
|
||||||
|
if method != "tasks/list" {
|
||||||
|
t.Errorf("expected method=tasks/list, got %q", method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- resolveAgentURL direct unit tests ---
|
// --- resolveAgentURL direct unit tests ---
|
||||||
|
|
||||||
func TestResolveAgentURL_CacheHit(t *testing.T) {
|
func TestResolveAgentURL_CacheHit(t *testing.T) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user