Skip to content

feat: SDK 1.x dual-stack support (v1 wire + legacy v0.3 compatibility) - #96

Open
ovrsr wants to merge 5 commits into
win4r:masterfrom
ovrsr:fix/95-sdk1-dual-stack
Open

ovrsr wants to merge 5 commits into
win4r:masterfrom
ovrsr:fix/95-sdk1-dual-stack

Conversation

@ovrsr

@ovrsr ovrsr commented Aug 25, 2026

Copy link
Copy Markdown

Closes #95.

What

Migrates the gateway client from @a2a-js/sdk 0.3.x to 1.0.1 while keeping full backward compatibility with v0.3 peers:

  • 12eca55 — close gateway executor resources on shutdown (fixes the exit-hang observed in the test matrix; see Interop failure with A2A v1.0 peers: client-side message/send hangs (one-directional) — proposal: dual-protocol support via @a2a-js/sdk 1.x #95 discussion)
  • 1de58aa — core type migration to SDK 1.0.1 (~60 type errors resolved: protobuf enums, $case Part unions, wrapped execution events, v1 Agent Cards, TaskStore.list(), SendMessageRequest)
  • 7826453 — dual-stack negotiation: agent card advertises supportedInterfaces[] with JSONRPC v1.0 first, v0.3 second for legacy negotiation; client auto-detects peer version from the fetched card and speaks v1 (SendMessage) or falls back to a direct legacy v0.3 JSON-RPC path (sendViaLegacyJsonRpc) since SDK 1.x transports reject v0.3 cards
  • 3ee7bdf — scripts/verify-e2e.mts, a manual dual-stack smoke test that boots a real SDK 1.x handler with legacyCompat enabled

Verification

  • npx tsc --noEmit: clean
  • Full suite: 514 passed / 0 failed / 0 cancelled (103 suites), fresh run
  • Live E2E (scripts/verify-e2e.mts):
    • A. v1 outbound → real SDK 1.x server (DefaultRequestHandler + jsonRpcHandler, legacyCompat enabled): PASS
    • B. legacy v0.3 outbound → same real server (v0.3-only card triggers fallback): PASS
    • C. live check against a production v0.3 gateway over LAN with bearer auth: PASS
  • Parent+child SIGUSR2 handle dumps confirm the pre-fix hang was exit-path resource retention, not leaked sockets or protocol failures

Notes

ovrsr added 5 commits August 24, 2026 21:30
- v1 protobuf-flavored types throughout (Part $case unions, TaskState/Role enums,
  AgentExecutionEvent wrapping, SendMessageRequest)
- new src/types-v1.ts helpers for part/message construction + legacy-part coercion
- FileTaskStore implements v1 TaskStore.list() with filtering/pagination
- AgentCard builder emits supportedInterfaces[] incl. dual-stack JSONRPC 1.0+0.3
- client normalizes both card generations into transport fallback list
- inbound extraction handles both v0.3 and v1 part shapes

tsc clean; matrix 470/514 pass (remaining failures = tests asserting v0.3 shapes)
Boots a real SDK 1.x JSON-RPC handler (legacyCompat enabled) on loopback
and exercises both client paths:
  A. v1 outbound -> real SDK server
  B. legacy v0.3 outbound -> same server (v0.3-only card)
  C. optional live check against a production v0.3 peer

Usage: npx tsx scripts/verify-e2e.mts
@ovrsr

ovrsr commented Aug 25, 2026

Copy link
Copy Markdown
Author

Additional local v0.3 verification

To distinguish a v0.3-shaped card on the v1 SDK from a genuine legacy peer, I created an isolated fixture pinned to @a2a-js/sdk@0.3.13.

  • Real v0.3 Agent Card (protocolVersion: "0.3.0")
  • Real v0.3 JSON-RPC server (DefaultRequestHandler + jsonRpcHandler)
  • New dual-stack client selected sendViaLegacyJsonRpc
  • End-to-end response: v03:compatibility-probe
  • Result: passed

This supplies the missing local backward-compatibility evidence. The live Prax check remains environmental and is still blocked while Prax is offline.

@DrumRobot DrumRobot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal Code Review — requesting-code-review

Second independent perspective (superpowers code-reviewer), separate from CodeRabbit's bot-layer review (see the AI Review Summary for the full reviewer matrix including CodeRabbit findings).

Prior to this review, the reviewer's principal independently verified the PR's core claim: built this PR locally (npm install + npm run build, 0 tsc errors), reran the test suite (514/514 passed, matching the PR description), and ran a live handshake against a real A2A v1.0 server (ericabouaf/claude-a2a, GitHub HEAD build) using this PR's own a2a-send.mjs client — the handshake succeeded end-to-end (discover + message/send round trip). This review covers code quality/architecture only.

Strengths

  1. Well-designed dual-stack architecture — v0.3 vs v1.0 card detection is sound. Legacy detection via absent supportedInterfaces / protocolVersion prefix cleanly separates the two paths.
  2. Robust part conversion (types-v1.ts) — toV1Part handles v0.3 TextPart/FilePart/DataPart, plain strings, and already-v1 protobuf parts, with a pragmatic JSON-stringify fallback for unknown shapes.
  3. Backward-compatible card generation — advertising both v1.0 and v0.3 JSONRPC on the same URL (agent-card.ts) lets legacy peers negotiate down without a second fetch.
  4. Adaptive transport selection (transport-fallback.ts) — composite scoring (success rate × latency) with a sliding window is a mature design.
  5. Comprehensive test coverage — 27 new tests in compat-matrix.test.ts covering v0.3/v1.0 cards, message formats, auth headers, transport fallback, and edge cases (empty parts, missing fields, forward-compat unknown fields).
  6. Clear separation of concerns — the legacy v0.3 path (sendViaLegacyJsonRpc) is fully isolated from the SDK v1 path.

Issues

Critical (Must Fix)

None identified — protocol negotiation is sound and the test suite is thorough.

Important (Should Fix)

  1. src/client.ts:332-334 — Silent fallback when no transport interfaces are found masks a malformed Agent Card instead of surfacing it.

    • If both the v0.3 and v1.0 interface-building loops push nothing, the code falls back to pushEndpoint(legacyCard.url, "JSONRPC") without verifying legacyCard.url exists.
    • Impact: a peer that advertises no interfaces at all (empty/malformed card) silently defaults to a JSONRPC transport that may not exist, producing a confusing downstream error instead of a clear "no transport interfaces" failure.
    • Suggested fix: only fall back when legacyCard.url is actually present; otherwise return an explicit error (e.g. { ok: false, statusCode: 500, response: { error: "Agent Card has no service URL and no supported interfaces" } }).
  2. src/client.ts:298-300 — cardIsLegacy OR-condition is partially redundant and worth a comment.

    • supportedIfaces.length === 0 || protocolVersion.startsWith("0.") — the primary signal (supportedIfaces.length === 0) is already sufficient and robust; the second condition adds defensive coverage for a field (protocolVersion) that may be optional per spec, but the rationale isn't documented.
    • Not a bug (a v1.0 card with protocolVersion omitted still correctly falls through since supportedIfaces.length > 0), but a short comment on the dual-condition rationale would help future maintainers.
  3. src/client.ts:305-312 — v0.3 → v1 field-name mapping (additionalInterfaces[].transport → protocolBinding) works but isn't called out explicitly, which can confuse a maintainer reading only one side of the mapping.

    • Suggested: an inline comment naming both field names side-by-side where the mapping happens (lines 326-330).

Minor (Nice to Have)

  1. src/client.ts:311,326-328 — legacyCard.additionalInterfaces is typed as an array but not runtime-validated; a peer sending a non-array value here is silently treated as falsy/empty. An Array.isArray() guard would make this explicit rather than incidental.
  2. src/types-v1.ts:468-477 — toV1Part's fallback (JSON.stringify) for unrecognized v0.3 part shapes doesn't explicitly preserve fields like metadata/annotations if a v0.3 part were to carry them. Low impact — current v0.3 parts are limited to text/file/data, which are all handled.
  3. src/client.ts:339-341 — the "adaptive ordering when we have stats" comment doesn't mention that adaptive ordering is skipped entirely when no transport has any recorded stats yet (falls back to static orderTransports). Behavior is correct; comment could be more precise.

Recommendations

  • Document the v0.3 wire format (LEGACY_METHOD_MESSAGE_SEND and friends) with a pointer to where the v0.3 spec constants are anchored (@a2a-js/sdk/compat/v0_3), for future maintainers.
  • An E2E test against a real (not mocked) v0.3 peer would complement the existing mock-based test suite — this PR's own scripts/verify-e2e.mts already does something like this manually; formalizing it as CI-runnable (with a fixture peer instead of a hardcoded path) would be valuable.
  • Consider basic instrumentation on transport fallback outcomes (attempts/success per transport per peer) to observe negotiation behavior in production over time.

Assessment

Ready to merge? With fixes

Reasoning: The dual-stack negotiation is well-architected and the independently-verified live handshake against a real v1.0 peer confirms it works end-to-end. No Critical issues found in this review. See the AI Review Summary for the full findings list including CodeRabbit's independently-verified findings.

Comment thread src/client.ts
Comment on lines +332 to 334
if (allInterfaces.length === 0) {
pushEndpoint(legacyCard.url, "JSONRPC");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: this fallback pushes legacyCard.url as a JSONRPC endpoint whenever no interfaces were found in either card generation, without checking legacyCard.url actually exists. A peer with a malformed/empty card silently gets routed to a transport that may not exist, instead of a clear "no transport interfaces" error. Suggest gating this on legacyCard.url being present, else returning an explicit error.

Comment thread src/client.ts
Comment on lines +298 to +300
const cardIsLegacy =
supportedIfaces.length === 0 ||
String(cardAny.protocolVersion ?? "").startsWith("0.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor clarity note: supportedIfaces.length === 0 already fully covers legacy detection (a v1.0 card always has supportedInterfaces); the protocolVersion.startsWith("0.") OR-branch is defensive for a field that's optional per spec. Not a bug, but a short comment here on why both conditions exist would help future readers.

Comment thread src/client.ts
Comment on lines +305 to +312
// Build transport list, normalizing BOTH card generations:
// v0.3: url + preferredTransport + additionalInterfaces[]
// v1: supportedInterfaces[] ({ url, protocolBinding })
const legacyCard = agentCard as unknown as {
url?: string;
preferredTransport?: string;
additionalInterfaces?: { url?: string; transport?: string }[];
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v0.3 → v1 field mapping (additionalInterfaces[].transport → protocolBinding) works correctly where it's applied (lines 326-330 below), but isn't called out explicitly at the mapping site — an inline comment there naming both field names side-by-side would reduce the chance of a future edit breaking the mapping without realizing it's a cross-version rename.

@DrumRobot DrumRobot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeRabbit CLI Review (local) — bot-layer substitute

This repository isn't connected to a CodeRabbit organization the reviewer's principal can access, so no cloud walkthrough was available. Per this workflow's policy, coderabbit review --agent -t all was run locally against this PR's worktree as a bot-layer substitute (separate from the Internal Code Review, which is an independent second perspective from a different engine).

CodeRabbit CLI reported 4 findings. Each was verified against the code at HEAD (6ad2716) before inclusion here:

Finding File Verdict
params.status filtering should treat omitted status as UNSPECIFIED src/task-store.ts:148-153 False positive — ListTasksRequest.status is a non-optional protobuf3 scalar field (status: TaskState, verified against @a2a-js/sdk's type declarations), so it can never be undefined; the existing check (params.status !== TaskState.TASK_STATE_UNSPECIFIED) is already correct. No change needed.
Raw Uint8Array/Buffer handling before base64 encoding src/executor.ts:1668-1675 Valid — see inline comment below.
Hardcoded peer-file path in verify script scripts/verify-e2e.mts:81-82 Valid, low impact — developer-machine-specific path in a local verification script (not shipped code); the missing-file case already degrades gracefully to SKIP (no token). An env-var override with this path as fallback would make it portable for other contributors.
Invalid/non-absolute agentCard.url can throw during register() src/agent-card.ts:43 Valid, low impact — new URL(jsonRpcUrl).origin throws uncaught if agentCard.url is configured but not a valid absolute URL. Validating (or a try/catch fallback to fallbackUrl) would turn a misconfiguration into a clear error instead of an uncaught exception.

Assessment

One valid finding worth fixing before merge (executor.ts, inline below); the other two valid findings are low-impact polish items; one was a false positive on verification.

Comment thread src/executor.ts
Comment on lines +1668 to +1675
if (v1Content.$case === "raw") {
const bytes =
Buffer.isBuffer(v1Content.value)
? (v1Content.value as Buffer).toString("base64")
: String(v1Content.value ?? "");
results.push({ kind: "file", file: { bytes, mimeType, name: filename } });
return results;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeRabbit CLI finding, verified: Buffer.isBuffer(v1Content.value) is false for a plain (non-Buffer) Uint8Array. In that branch, String(v1Content.value) produces a comma-separated decimal string (e.g. "1,2,3"), not base64 — this would corrupt the payload if the underlying gRPC/protobuf client ever hands back a plain Uint8Array instead of a Node Buffer for a bytes field. Suggest converting through Buffer.from(...) for any Uint8Array before the base64 encode, regardless of whether it is specifically a Buffer instance.

@AliceLJY AliceLJY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough spike. I won't repeat DrumRobot's points. I checked this statically against @a2a-js/sdk@1.0.1 and found two regressions in the direction that matters most for existing users: current peers calling into an upgraded gateway.

  1. Inbound v0.3 is not enabled. In SDK 1.0.1, agentCardHandler only serves the compat card (with url/protocolVersion) through legacyAgentCardRouter, and jsonRpcHandler only routes message/send to LegacyJsonRpcTransportHandler, when legacyCompat: { enabled: true } is passed. index.ts:506 and :516 pass neither. Result:

    • A peer still on current master gets a card with no url.
    • A raw v0.3 message/send gets -32601 Invalid method.

    Listing protocolVersion: "0.3" in supportedInterfaces doesn't help, because v0.3 clients don't read that field. The compat-matrix tests that used message/send were rewritten to SendMessage, so nothing covers this path any more. Please enable legacyCompat on the card and JSON-RPC handlers (and REST, if you intend to support it), and add tests that (a) fetch the card with no A2A-Version header and assert url is present, and (b) POST a v0.3 message/send and get a result.

  2. agentId routing between two upgraded gateways is lost. client.ts:257 now puts agentId in message.metadata, which is correct. But pickAgentId (executor.ts:56-60) still reads only the top-level msg.agentId, so the receiver always falls back to the default agent. Please read metadata.agentId too, and add a round-trip test.

  3. Minor: the description says the suites need --test-force-exit, but the package.json test script is unchanged, and that is what CI runs. Please confirm plain npm test finishes on Node 22 and 25, or update the script.

This was referenced Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interop failure with A2A v1.0 peers: client-side message/send hangs (one-directional) — proposal: dual-protocol support via @a2a-js/sdk 1.x

3 participants