Skip to content

gRPC: Task/Message metadata field with a null value crashes serialization ("invalid int32: object") #576

Description

@jasonchaffee

Summary

Value.wrap() in the generated gRPC protobuf bindings (dist/chunk-*.js, compiled from src/grpc/pb/google/protobuf/struct.ts) sets the nullValue oneof case to the literal JS null instead of the NullValue.NULL_VALUE enum value (0). @bufbuild/protobuf's wire encoder strictly validates int32 fields and throws when it receives null, so any Task/Message (or other Struct-typed) field containing a JSON null value crashes gRPC serialization with Error: invalid int32: object.

The A2A spec defines metadata as "a flexible key-value map ... values can be any valid value that can be represented in JSON" — null is valid JSON, so this isn't a caller error, it's a bug in the wire encoding of the well-known Struct/Value types.

This is a known class of pitfall in ts-proto-generated code for google.protobuf.NullValue (see stephenh/ts-proto#458) — the fix is to map null to 0 (not pass null straight through) before writing the int32 field.

Reproduction

import { A2AService } from '@a2a-js/sdk/server/grpc'

const fakeTask = {
  id: 'task-1',
  contextId: 'ctx-1',
  status: { state: 1, timestamp: new Date().toISOString() },
  artifacts: [],
  history: [],
  metadata: { some_field: null },   // <-- valid per spec, real-world example: a numeric field that's legitimately absent
}

A2AService.sendMessage.responseSerialize({ payload: { $case: 'task', value: fakeTask } })

Observed

Error: invalid int32: object
    at assertInt32 (@bufbuild/protobuf/dist/esm/wire/binary-encoding.js:481:15)
    at BinaryWriter.int32 (@bufbuild/protobuf/dist/esm/wire/binary-encoding.js:176:9)
    at Object.encode (chunk-QQCCX2KH.js:141:26)   // Value.encode(), nullValue case
    at Object.encode (chunk-QQCCX2KH.js:100:13)   // Struct_FieldsEntry.encode()
    at chunk-QQCCX2KH.js:42:28                    // Struct.encode()'s forEach
    at Array.forEach (<anonymous>)
    at Object.encode (chunk-QQCCX2KH.js:40:36)
    at Object.encode (chunk-QQCCX2KH.js:428:14)   // Task.encode()
    at Object.encode (chunk-QQCCX2KH.js:3060:14)  // SendMessageResponse.encode()
    at Object.responseSerialize (chunk-QQCCX2KH.js:3217:67)

Root cause

In the generated bindings:

// Value.wrap()
wrap(value) {
    const result = createBaseValue();
    if (value === null) {
      result.kind = { $case: "nullValue", value };   // <-- sets value: null, should be 0
    }
    ...
// Value.encode()
case "nullValue":
    writer.uint32(8).int32(message.kind.value);   // message.kind.value is literal `null` here
    break;

@bufbuild/protobuf's BinaryWriter.int32() calls assertInt32, which rejects anything that isn't a number — null fails typeof value === 'number', hence the thrown error.

Suggested fix

In Value.wrap(), when value === null, set result.kind = { $case: "nullValue", value: 0 } (the NullValue.NULL_VALUE enum member), not the literal JS null.

Environment

  • @a2a-js/sdk: 0.3.13
  • @bufbuild/protobuf: (whatever is pinned as a peer/transitive dep at the time of testing)
  • Node.js 26.5.0
  • Encountered via the sdk's gRPC server transport (@a2a-js/sdk/server/grpc), triggered by two independent downstream services returning a Task/tool-result with a legitimately-null metadata field over gRPC.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions