forked from molecule-ai/molecule-core
test(a2a): protocol-shape replay corpus gate (#2345 follow-up)
Backward-compat replay gate for the A2A JSON-RPC protocol surface.
Every PR that touches normalizeA2APayload OR bumps the a-2-a-sdk
version pin runs every shape in testdata/a2a_corpus/ through the
current code and asserts:
valid/ — every shape MUST parse without error and produce a
canonical v0.3 payload (params.message.parts list).
invalid/ — every shape MUST be rejected with the documented
status code and error substring.
What this prevents
The 2026-04-29 v0.2 → v0.3 silent-drop bug (PR #2349) shipped
because the SDK bump PR didn't replay v0.2-shaped inputs against
the new code; the shape-mismatch surfaced only in production when
the receiver's Pydantic validator silently rejected inbound
messages.
This gate would have caught it pre-merge. Hand-verified: reverting
the v0.2 string→parts shim in normalizeA2APayload fails 3 of the
v0.2 corpus entries with the exact rejection class the production
bug exhibited.
Corpus contents (11 entries)
valid/ (10):
v0_2_string_content — basic v0.2 (the broken case)
v0_2_string_content_no_message_id — v0.2 + auto-fill messageId
v0_2_list_content — v0.2 with content as Part list
v0_3_parts_text_only — canonical v0.3
v0_3_parts_multi_text — multi-Part list
v0_3_parts_with_file — multimodal (text + file)
v0_3_parts_with_context — contextId for multi-turn
v0_3_streaming_method — message/stream variant
v0_3_unicode_text — emoji + multi-script
v0_3_long_text — 10KB text Part
no_jsonrpc_envelope — bare params/method without
outer envelope (legacy senders)
invalid/ (3):
no_content_or_parts — message has neither field
content_is_integer — wrong type for v0.2 content
content_is_bool — wrong type, separate from int
so the failure msg identifies
which type-class regressed
Plus 4 inline malformed-JSON cases (truncated, not-JSON, empty,
whitespace) that can't be expressed as JSON corpus entries.
Coverage tests
The gate has 4 test functions:
1. TestA2ACorpus_ValidShapesParse — replay valid/ corpus,
assert no error + canonical v0.3 output (parts list non-empty,
messageId non-empty, content field deleted).
2. TestA2ACorpus_InvalidShapesRejected — replay invalid/ corpus,
assert rejection matches recorded status + error substring.
3. TestA2ACorpus_MalformedJSONRejected — inline cases for
non-parseable bodies.
4. TestA2ACorpus_HasMinimumCoverage — at least one v0.2 +
one v0.3 entry exists (loses neither side of the bridge).
5. TestA2ACorpus_EveryEntryHasMetadata — _comment/_added/_source
on every entry per the README policy; _expect_error and
_expect_status on invalid entries.
Documentation
testdata/a2a_corpus/README.md describes the corpus contract:
- When to add entries (new SDK shape, new production-observed
shape).
- When NOT to add (test scaffolding, hypothetical futures).
- Removal policy (breaking change, deprecation window required).
Verification
- All 24 corpus subtests pass on current main.
- Hand-test: revert the v0.2 compat shim → 3 v0.2 entries fail
the gate with the exact rejection class the production bug
exhibited. Confirmed.
- Whole-module go test ./... green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
344e3e8914
commit
747c12e582
318
workspace-server/internal/handlers/a2a_corpus_test.go
Normal file
318
workspace-server/internal/handlers/a2a_corpus_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
package handlers
|
||||
|
||||
// a2a_corpus_test.go — backward-compat replay gate for the A2A
|
||||
// JSON-RPC protocol surface. Every PR that touches
|
||||
// normalizeA2APayload OR bumps the a-2-a-sdk version pin runs
|
||||
// every shape in testdata/a2a_corpus/ through the current code
|
||||
// and asserts:
|
||||
//
|
||||
// valid/ — every shape MUST parse without error and produce a
|
||||
// canonical v0.3 payload (params.message.parts list).
|
||||
//
|
||||
// invalid/ — every shape MUST be rejected with the documented
|
||||
// status code and error substring. Pins the
|
||||
// rejection contract so a future PR doesn't silently
|
||||
// start accepting malformed payloads.
|
||||
//
|
||||
// Closes the gap that allowed the 2026-04-29 v0.2 → v0.3 silent-
|
||||
// drop bug (PR #2349). That bug shipped because the SDK bump PR
|
||||
// didn't replay v0.2-shaped inputs against the new code; the
|
||||
// shape-mismatch surfaced only in production when the receiver's
|
||||
// Pydantic validator silently rejected inbound messages.
|
||||
//
|
||||
// Adding to the corpus: see testdata/a2a_corpus/README.md.
|
||||
// Removing from valid/: breaking change, requires explicit
|
||||
// approval per the README.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
corpusValidDir = "testdata/a2a_corpus/valid"
|
||||
corpusInvalidDir = "testdata/a2a_corpus/invalid"
|
||||
)
|
||||
|
||||
// metadataFields are the documentation-only keys the corpus loader
|
||||
// strips before passing the payload to normalizeA2APayload. They
|
||||
// are required for every corpus entry per the README policy.
|
||||
var metadataFields = []string{
|
||||
"_comment",
|
||||
"_added",
|
||||
"_source",
|
||||
"_expect_error",
|
||||
"_expect_status",
|
||||
}
|
||||
|
||||
// loadCorpusEntry reads one JSON file, parses it as a generic map,
|
||||
// extracts the metadata fields (including expected error/status for
|
||||
// invalid entries), strips them from the payload, and returns the
|
||||
// stripped JSON bytes ready for normalizeA2APayload.
|
||||
func loadCorpusEntry(t *testing.T, path string) (payload []byte, expectErr string, expectStatus int) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("parse %s as JSON: %v", path, err)
|
||||
}
|
||||
// Pull metadata before strip.
|
||||
if v, ok := doc["_expect_error"].(string); ok {
|
||||
expectErr = v
|
||||
}
|
||||
if v, ok := doc["_expect_status"].(float64); ok {
|
||||
expectStatus = int(v)
|
||||
}
|
||||
for _, f := range metadataFields {
|
||||
delete(doc, f)
|
||||
}
|
||||
payload, err = json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal %s after strip: %v", path, err)
|
||||
}
|
||||
return payload, expectErr, expectStatus
|
||||
}
|
||||
|
||||
// listCorpus enumerates every .json file under dir and returns
|
||||
// (filename → full path). Sorted for stable test ordering.
|
||||
func listCorpus(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
out[e.Name()] = filepath.Join(dir, e.Name())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("corpus dir %s is empty — at least one entry is required", dir)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestA2ACorpus_ValidShapesParse replays every entry in valid/
|
||||
// through normalizeA2APayload and asserts:
|
||||
// 1. No error returned.
|
||||
// 2. The output's params.message.parts is a non-empty list
|
||||
// (v0.3 canonical shape — the compat shim must have converted
|
||||
// any v0.2 content field into parts).
|
||||
// 3. The output's params.message.messageId is non-empty (the
|
||||
// normalizer auto-fills if the sender omitted it).
|
||||
// 4. The output's method matches the input's method (the
|
||||
// normalizer is method-agnostic).
|
||||
//
|
||||
// One subtest per corpus entry — failures point directly at the
|
||||
// offending shape file.
|
||||
func TestA2ACorpus_ValidShapesParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, path := range listCorpus(t, corpusValidDir) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
payload, _, _ := loadCorpusEntry(t, path)
|
||||
|
||||
normalized, method, perr := normalizeA2APayload(payload)
|
||||
if perr != nil {
|
||||
t.Fatalf("valid/%s: normalizeA2APayload returned error %d: %v",
|
||||
name, perr.Status, perr.Response)
|
||||
}
|
||||
|
||||
// Read back the normalized payload to verify shape invariants.
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(normalized, &parsed); err != nil {
|
||||
t.Fatalf("valid/%s: normalized output not valid JSON: %v", name, err)
|
||||
}
|
||||
|
||||
// Method-agnostic check — input method survives normalization.
|
||||
if input := mustGetString(t, parsed, "method"); input != method {
|
||||
t.Errorf("valid/%s: method mismatch — got %q, want %q",
|
||||
name, method, input)
|
||||
}
|
||||
|
||||
// Canonical v0.3 shape invariants: params.message.parts is a
|
||||
// non-empty list, messageId is non-empty.
|
||||
params := mustGetMap(t, parsed, "params")
|
||||
msg := mustGetMap(t, params, "message")
|
||||
|
||||
parts, ok := msg["parts"].([]interface{})
|
||||
if !ok {
|
||||
t.Errorf("valid/%s: params.message.parts is not a list (got %T)",
|
||||
name, msg["parts"])
|
||||
return
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
t.Errorf("valid/%s: params.message.parts is empty — compat shim should have converted content", name)
|
||||
}
|
||||
|
||||
if id := mustGetString(t, msg, "messageId"); id == "" {
|
||||
t.Errorf("valid/%s: params.message.messageId is empty after normalization", name)
|
||||
}
|
||||
|
||||
// content must NOT survive into the output — the shim
|
||||
// deletes it after converting to parts. If the shim left
|
||||
// content in place, downstream pydantic v0.3 would still
|
||||
// reject because it doesn't know that field.
|
||||
if _, hasContent := msg["content"]; hasContent {
|
||||
t.Errorf("valid/%s: params.message.content survived normalization (compat shim should delete it)", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestA2ACorpus_InvalidShapesRejected replays every entry in
|
||||
// invalid/ through normalizeA2APayload and asserts the rejection
|
||||
// matches the documented contract — same status code AND error
|
||||
// substring as recorded in the corpus entry's metadata.
|
||||
//
|
||||
// Catches the regression class "future PR adds permissive defaults
|
||||
// that silently accept what we used to reject loud."
|
||||
func TestA2ACorpus_InvalidShapesRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, path := range listCorpus(t, corpusInvalidDir) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
payload, expectErr, expectStatus := loadCorpusEntry(t, path)
|
||||
|
||||
if expectErr == "" {
|
||||
t.Fatalf("invalid/%s: missing _expect_error metadata", name)
|
||||
}
|
||||
if expectStatus == 0 {
|
||||
t.Fatalf("invalid/%s: missing _expect_status metadata", name)
|
||||
}
|
||||
|
||||
_, _, perr := normalizeA2APayload(payload)
|
||||
if perr == nil {
|
||||
t.Fatalf("invalid/%s: normalizeA2APayload returned no error — should have rejected", name)
|
||||
}
|
||||
if perr.Status != expectStatus {
|
||||
t.Errorf("invalid/%s: status = %d, want %d", name, perr.Status, expectStatus)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(perr.Response)
|
||||
if !strings.Contains(string(body), expectErr) {
|
||||
t.Errorf("invalid/%s: error response %q does not contain expected substring %q",
|
||||
name, string(body), expectErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestA2ACorpus_MalformedJSONRejected covers the case where the
|
||||
// body isn't valid JSON at all. The corpus is JSON-only so this
|
||||
// can't be expressed as a corpus entry; pin the contract inline.
|
||||
func TestA2ACorpus_MalformedJSONRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
}{
|
||||
{"truncated_object", []byte(`{"jsonrpc":"2.0","method":"message/send"`)},
|
||||
{"not_json_at_all", []byte(`this is not json`)},
|
||||
{"empty_body", []byte(``)},
|
||||
{"only_whitespace", []byte(` `)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, perr := normalizeA2APayload(tc.payload)
|
||||
if perr == nil {
|
||||
t.Fatalf("expected error for %s, got none", tc.name)
|
||||
}
|
||||
if perr.Status != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", perr.Status, http.StatusBadRequest)
|
||||
}
|
||||
body, _ := json.Marshal(perr.Response)
|
||||
if !strings.Contains(string(body), "invalid JSON") {
|
||||
t.Errorf("expected 'invalid JSON' in response, got %q", string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestA2ACorpus_HasMinimumCoverage pins the corpus's
|
||||
// representativeness. The corpus must have at least one v0.2
|
||||
// entry (string content) and at least one v0.3 entry (parts list)
|
||||
// — losing either side of the schema bridge would silently drop
|
||||
// the most important coverage.
|
||||
func TestA2ACorpus_HasMinimumCoverage(t *testing.T) {
|
||||
t.Parallel()
|
||||
files := listCorpus(t, corpusValidDir)
|
||||
hasV02 := false
|
||||
hasV03 := false
|
||||
for name := range files {
|
||||
if strings.Contains(name, "v0_2_") {
|
||||
hasV02 = true
|
||||
}
|
||||
if strings.Contains(name, "v0_3_") {
|
||||
hasV03 = true
|
||||
}
|
||||
}
|
||||
if !hasV02 {
|
||||
t.Error("corpus has no v0_2_*.json entries — backward-compat coverage missing")
|
||||
}
|
||||
if !hasV03 {
|
||||
t.Error("corpus has no v0_3_*.json entries — forward (canonical) coverage missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestA2ACorpus_EveryEntryHasMetadata pins the README policy:
|
||||
// every corpus entry MUST have _comment, _added, _source. Catches
|
||||
// the bad commit shape "added entry without explanation" before
|
||||
// review.
|
||||
func TestA2ACorpus_EveryEntryHasMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, dir := range []string{corpusValidDir, corpusInvalidDir} {
|
||||
for name, path := range listCorpus(t, dir) {
|
||||
t.Run(filepath.Base(dir)+"/"+name, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
required := []string{"_comment", "_added", "_source"}
|
||||
if dir == corpusInvalidDir {
|
||||
required = append(required, "_expect_error", "_expect_status")
|
||||
}
|
||||
for _, key := range required {
|
||||
if _, ok := doc[key]; !ok {
|
||||
t.Errorf("missing required metadata field %q", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetMap(t *testing.T, m map[string]interface{}, key string) map[string]interface{} {
|
||||
t.Helper()
|
||||
v, ok := m[key].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected %q to be a map, got %T", key, m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetString(t *testing.T, m map[string]interface{}, key string) string {
|
||||
t.Helper()
|
||||
v, ok := m[key].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected %q to be a string, got %T", key, m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// _ silences the unused-import linter for fmt in case future
|
||||
// helpers don't use it. Currently used by the t.Helper-style
|
||||
// formatters above (kept inline for clarity).
|
||||
var _ = fmt.Sprintf
|
||||
81
workspace-server/internal/handlers/testdata/a2a_corpus/README.md
vendored
Normal file
81
workspace-server/internal/handlers/testdata/a2a_corpus/README.md
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
# A2A protocol replay corpus
|
||||
|
||||
Captures every A2A JSON-RPC message shape the platform has ever
|
||||
accepted, so a future PR that bumps a-2-a-sdk or modifies
|
||||
`normalizeA2APayload` can be tested against historical inputs
|
||||
before merging.
|
||||
|
||||
This is the gate that would have caught the 2026-04-29 v0.2 → v0.3
|
||||
silent-drop bug (PR #2349). The bug shipped because the SDK bump
|
||||
PR didn't replay v0.2-shaped inputs against the new code; the
|
||||
shape-mismatch surfaced only in production when the receiver's
|
||||
Pydantic validator silently rejected inbound messages.
|
||||
|
||||
## Layout
|
||||
|
||||
- `valid/` — every shape that has ever been accepted. Each PR that
|
||||
changes the protocol code OR bumps the SDK pin runs every entry
|
||||
through `normalizeA2APayload` and asserts it parses without
|
||||
error. Removing an entry from this directory is a breaking
|
||||
change and requires explicit operator approval.
|
||||
|
||||
- `invalid/` — shapes that MUST be rejected with the right error
|
||||
type. These pin the rejection contract — a future PR that
|
||||
silently accepts a malformed shape (because, say, it added a
|
||||
permissive default) breaks the gate.
|
||||
|
||||
## When to add a corpus entry
|
||||
|
||||
- A new SDK version is released and adds a shape we want to support.
|
||||
Capture a representative payload (PII-scrubbed) before bumping
|
||||
the SDK pin. The same PR that bumps the SDK adds the corpus
|
||||
entry AND any required compat shim in `normalizeA2APayload`.
|
||||
|
||||
- Production logs surface a shape we hadn't seen before. Capture
|
||||
it after PII-scrubbing. Even if it currently rejects, decide
|
||||
whether to support it (move to `valid/`) or to keep rejecting
|
||||
loudly (move to `invalid/`).
|
||||
|
||||
## When NOT to add an entry
|
||||
|
||||
- Test scaffolding. The corpus is the SHAPE record, not unit-test
|
||||
coverage. Use the regular handler tests for functional coverage.
|
||||
|
||||
- Hypothetical future shapes. Add only what we have evidence for
|
||||
— either a real payload or an SDK release note.
|
||||
|
||||
## Removal policy
|
||||
|
||||
Removing an entry from `valid/` is a breaking change for any sender
|
||||
emitting that shape. Requires:
|
||||
|
||||
1. A migration plan (deprecation window, sender notification).
|
||||
2. A separate PR with a single-line removal + a comment explaining
|
||||
the deprecation timeline.
|
||||
3. Approval from someone outside the PR author's team.
|
||||
|
||||
Removing an entry from `invalid/` widens what we accept. Lower bar:
|
||||
just verify the new behavior is intentional and the corpus entry
|
||||
moves to `valid/`.
|
||||
|
||||
## Anatomy of a corpus entry
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "v0.2 string content — basic text message via message/send",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "PR #2349 incident, real payload from sender workspace",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "test-id",
|
||||
"params": {
|
||||
"message": {
|
||||
"content": "hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`_comment`, `_added`, `_source` are documentation that the test
|
||||
loader strips before passing the payload to the parser. They are
|
||||
required for every entry.
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/content_is_bool.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/content_is_bool.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "content as bool is not a valid type. Same rejection class as integer — pinned separately so the test failure message identifies WHICH wrong type is the regression.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — type-rejection branch coverage",
|
||||
"_expect_error": "invalid params.message.content type",
|
||||
"_expect_status": 400,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-invalid-bool",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-bad-3",
|
||||
"role": "user",
|
||||
"content": true
|
||||
}
|
||||
}
|
||||
}
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/content_is_integer.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/content_is_integer.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "content as integer is not a valid type. The compat shim accepts string (v0.2 plain text) or list (v0.2 Part list); anything else is rejected with HTTP 400 and a hint.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — pins the type-rejection branch",
|
||||
"_expect_error": "invalid params.message.content type",
|
||||
"_expect_status": 400,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-invalid-int",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-bad-2",
|
||||
"role": "user",
|
||||
"content": 42
|
||||
}
|
||||
}
|
||||
}
|
||||
16
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/no_content_or_parts.json
vendored
Normal file
16
workspace-server/internal/handlers/testdata/a2a_corpus/invalid/no_content_or_parts.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"_comment": "params.message has neither content nor parts. Pre-PR-2349 the SDK silently rejected this post-handler-dispatch and the rejection was invisible to the sender. Now normalizeA2APayload returns a loud HTTP 400.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "PR #2349 incident edge case",
|
||||
"_expect_error": "params.message must contain either 'parts' (v0.3) or 'content' (v0.2 compat)",
|
||||
"_expect_status": 400,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-invalid-empty",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-bad-1",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
workspace-server/internal/handlers/testdata/a2a_corpus/valid/no_jsonrpc_envelope.json
vendored
Normal file
15
workspace-server/internal/handlers/testdata/a2a_corpus/valid/no_jsonrpc_envelope.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "Body without an outer jsonrpc envelope. The normalizer wraps it in {jsonrpc:2.0, id:<uuid>, method, params}. Some legacy senders post the bare params + method without the envelope.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Pre-RFC sender behavior",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-8",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "no envelope here"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_2_list_content.json
vendored
Normal file
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_2_list_content.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "v0.2 with content already a list (e.g. multimodal Part shape). Some pre-v0.3 senders constructed the list form even before the v0.3 rename — usually because they were copying from working v0.3 examples but still pinned to a-2-a-sdk v0.2 by mistake. The compat shim accepts both list and string for content.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — covers normalizeA2APayload's []interface{} switch case",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v02-list",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-2",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"kind": "text", "text": "hello"},
|
||||
{"kind": "text", "text": "world"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_2_string_content.json
vendored
Normal file
15
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_2_string_content.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "v0.2 string content — the most common pre-rename shape. Caused the 2026-04-29 silent-drop bug: a-2-a-sdk v0.3 renamed params.message.content (string) -> params.message.parts (Part list). Shipped PR #2349 to compat-shim this in normalizeA2APayload.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "PR #2349 incident reproduction",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v02-string",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-1",
|
||||
"role": "user",
|
||||
"content": "hello world"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"_comment": "v0.2 without messageId — normalizeA2APayload must add a generated UUID. Some early senders omitted messageId entirely and relied on the platform to generate one.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic, covers the messageId-injection branch",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v02-no-id",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": "no message id supplied"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_long_text.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_long_text.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "10KB text part. Pins that the normalizer doesn't choke on large payloads (no buffer assumption). Real briefs from the Brief-Pipeline experiment were occasionally this size.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — large-payload smoke",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-long",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-10",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_multi_text.json
vendored
Normal file
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_multi_text.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "v0.3 with multiple text parts. Used by senders that interleave system + user text or that segment a long input.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — pins multi-Part list handling",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v03-multi",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-4",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Context: you are a helpful assistant."},
|
||||
{"kind": "text", "text": "Question: what is 2+2?"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_text_only.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_text_only.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "v0.3 canonical: params.message.parts is a list of Part objects. This is what the platform's normalizer produces internally and what the a-2-a-sdk v0.3 Pydantic validator expects.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "a-2-a-sdk v0.3 release notes example",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v03-parts",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-3",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "hello world"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_with_context.json
vendored
Normal file
18
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_with_context.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "v0.3 with contextId — multi-turn conversation thread identifier. The a-2-a-sdk groups messages with the same contextId into a single conversation history.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Conversation-thread feature path",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v03-context",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-6",
|
||||
"contextId": "ctx-conversation-7",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Continuing from before — what did you find?"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_with_file.json
vendored
Normal file
25
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_parts_with_file.json
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"_comment": "v0.3 multimodal: text Part + file Part. Used by chat-attachment flow (PR #114).",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Real shape from agent-to-agent file attachment path",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-v03-file",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-5",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Please review the attached log."},
|
||||
{
|
||||
"kind": "file",
|
||||
"file": {
|
||||
"name": "incident.log",
|
||||
"mimeType": "text/plain",
|
||||
"uri": "https://staging-api.moleculesai.app/files/abc123"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_streaming_method.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_streaming_method.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "v0.3 with method=message/stream (vs message/send). The streaming path is otherwise identical at the request shape level — it differs in the response (event stream vs single response). Pins that the normalizer is method-agnostic.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Streaming dispatch path",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/stream",
|
||||
"id": "msg-test-v03-stream",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-7",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Stream me a long answer please."}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_unicode_text.json
vendored
Normal file
17
workspace-server/internal/handlers/testdata/a2a_corpus/valid/v0_3_unicode_text.json
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Unicode + emoji content. Pins that JSON encoding round-trips correctly through normalizeA2APayload's Marshal/Unmarshal pair. The 2026-03 emoji-truncation bug was at a different layer (display) but the underlying JSON path is the same.",
|
||||
"_added": "2026-04-30",
|
||||
"_source": "Synthetic — covers unicode round-trip",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "msg-test-unicode",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-9",
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Hello 你好 こんにちは 🚀 — ünicode test"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user