From 1411ebe0fd923341a914c674f0776dfbe428085c Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Thu, 13 Aug 2026 13:11:11 +0300 Subject: [PATCH 1/2] fix(ack-id)!: key the Zod A2A schemas on kind, not type The Zod A2A schemas still described the pre-0.3 A2A shape: parts were discriminated on `type`, and `messageSchema` required neither `kind: "message"` nor `messageId`. The Valibot schemas moved to the `@a2a-js/sdk` shape and the Zod ones were left behind, so the two entry points disagreed about the same message. A handshake message produced by `createA2AHandshakeMessage` failed Zod validation, while a message shaped for the Zod schemas was rejected at verification time by `verifyA2AHandshakeMessage`. Mirror the Valibot definitions and cover both schema families with the same cases so they cannot drift apart again. --- .changeset/a2a-zod-schema-kind.md | 19 +++++ .../ack-id/src/a2a/schemas/schemas.test.ts | 83 +++++++++++++++++++ packages/ack-id/src/a2a/schemas/zod.ts | 26 ++++-- 3 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 .changeset/a2a-zod-schema-kind.md create mode 100644 packages/ack-id/src/a2a/schemas/schemas.test.ts diff --git a/.changeset/a2a-zod-schema-kind.md b/.changeset/a2a-zod-schema-kind.md new file mode 100644 index 00000000..27b822b6 --- /dev/null +++ b/.changeset/a2a-zod-schema-kind.md @@ -0,0 +1,19 @@ +--- +"@agentcommercekit/ack-id": minor +--- + +The Zod A2A schemas now describe the same messages as their Valibot twins. + +`@agentcommercekit/ack-id/a2a/schemas/zod` still described the pre-0.3 A2A +shape: parts were discriminated on `type` rather than `kind`, and `messageSchema` +required neither `kind: "message"` nor `messageId`. The Valibot schemas moved to +the `@a2a-js/sdk` shape and the Zod ones were left behind, so the two entry +points disagreed about the same message. A handshake message produced by +`createA2AHandshakeMessage` failed Zod validation, while a message shaped for +the Zod schemas was rejected at verification time by `verifyA2AHandshakeMessage`. + +`messageSchema` now requires `kind: "message"` and `messageId` and accepts +`taskId`, `contextId`, `extensions`, and `referenceTaskIds`. `partSchema` +discriminates on `kind`. `fileContentSchema` is replaced by `fileWithBytesSchema` +and `fileWithUriSchema`, matching the Valibot exports, and a file part accepts +either. diff --git a/packages/ack-id/src/a2a/schemas/schemas.test.ts b/packages/ack-id/src/a2a/schemas/schemas.test.ts new file mode 100644 index 00000000..6add9355 --- /dev/null +++ b/packages/ack-id/src/a2a/schemas/schemas.test.ts @@ -0,0 +1,83 @@ +import * as v from "valibot" +import { describe, expect, it } from "vitest" + +import { createA2AHandshakeMessageFromJwt } from "../sign-message" +import { messageSchema as valibotMessageSchema } from "./valibot" +import { messageSchema as zodMessageSchema } from "./zod" + +// The two schema files are separate entry points onto the same A2A shape, so +// every case runs against both. A message that one accepts and the other +// rejects is exactly the drift these tests exist to catch. +const validators = { + valibot: (value: unknown) => v.safeParse(valibotMessageSchema, value).success, + zod: (value: unknown) => zodMessageSchema.safeParse(value).success, +} + +describe.each(Object.entries(validators))( + "A2A messageSchema (%s)", + (_name, accepts) => { + it("accepts a handshake message built by this package", () => { + expect(accepts(createA2AHandshakeMessageFromJwt("user", "a.b.c"))).toBe( + true, + ) + }) + + it("accepts a text message carrying task and context ids", () => { + expect( + accepts({ + kind: "message", + messageId: "msg-1", + role: "agent", + parts: [{ kind: "text", text: "hello" }], + taskId: "task-1", + contextId: "ctx-1", + }), + ).toBe(true) + }) + + it("accepts a file part", () => { + expect( + accepts({ + kind: "message", + messageId: "msg-1", + role: "user", + parts: [ + { kind: "file", file: { uri: "https://example.com/receipt.pdf" } }, + ], + }), + ).toBe(true) + }) + + // `@a2a-js/sdk` v0.3 keys parts on `kind`. `type` is the pre-0.3 spelling. + it("rejects a part keyed on type instead of kind", () => { + expect( + accepts({ + kind: "message", + messageId: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).toBe(false) + }) + + it("rejects a message without a message kind", () => { + expect( + accepts({ + messageId: "msg-1", + role: "user", + parts: [{ kind: "text", text: "hello" }], + }), + ).toBe(false) + }) + + it("rejects a message without a messageId", () => { + expect( + accepts({ + kind: "message", + role: "user", + parts: [{ kind: "text", text: "hello" }], + }), + ).toBe(false) + }) + }, +) diff --git a/packages/ack-id/src/a2a/schemas/zod.ts b/packages/ack-id/src/a2a/schemas/zod.ts index f6575bd0..99ff175d 100644 --- a/packages/ack-id/src/a2a/schemas/zod.ts +++ b/packages/ack-id/src/a2a/schemas/zod.ts @@ -11,32 +11,38 @@ const partBaseSchema = z.object({ // Text part schema export const textPartSchema = partBaseSchema.extend({ - type: z.literal("text"), + kind: z.literal("text"), text: z.string(), }) // Data part schema export const dataPartSchema = partBaseSchema.extend({ - type: z.literal("data"), + kind: z.literal("data"), data: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]), }) -// File content schema -export const fileContentSchema = z.object({ +// File content schemas +export const fileWithBytesSchema = z.object({ name: z.string().nullable().optional(), mimeType: z.string().nullable().optional(), bytes: z.string().nullable().optional(), uri: z.string().nullable().optional(), }) +export const fileWithUriSchema = z.object({ + name: z.string().nullable().optional(), + mimeType: z.string().nullable().optional(), + uri: z.string().nullable().optional(), +}) + // File part schema export const filePartSchema = partBaseSchema.extend({ - type: z.literal("file"), - file: fileContentSchema, + kind: z.literal("file"), + file: z.union([fileWithBytesSchema, fileWithUriSchema]), }) // Union of all part types using discriminated union -export const partSchema = z.discriminatedUnion("type", [ +export const partSchema = z.discriminatedUnion("kind", [ textPartSchema, dataPartSchema, filePartSchema, @@ -44,7 +50,13 @@ export const partSchema = z.discriminatedUnion("type", [ // Message schema export const messageSchema = z.looseObject({ + kind: z.literal("message"), + messageId: z.string(), role: roleSchema, parts: z.array(partSchema), metadata: metadataSchema.optional(), + taskId: z.string().optional(), + contextId: z.string().optional(), + extensions: z.array(z.string()).optional(), + referenceTaskIds: z.array(z.string()).optional(), }) From f7709ac3f4fdbb84924c53877b99d6999a5659e4 Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Thu, 13 Aug 2026 13:38:18 +0300 Subject: [PATCH 2/2] test(ack-id): cover the bytes variant of the A2A file part --- .../ack-id/src/a2a/schemas/schemas.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ack-id/src/a2a/schemas/schemas.test.ts b/packages/ack-id/src/a2a/schemas/schemas.test.ts index 6add9355..d6b5decd 100644 --- a/packages/ack-id/src/a2a/schemas/schemas.test.ts +++ b/packages/ack-id/src/a2a/schemas/schemas.test.ts @@ -35,7 +35,7 @@ describe.each(Object.entries(validators))( ).toBe(true) }) - it("accepts a file part", () => { + it("accepts a file part carrying a uri", () => { expect( accepts({ kind: "message", @@ -48,6 +48,22 @@ describe.each(Object.entries(validators))( ).toBe(true) }) + it("accepts a file part carrying bytes", () => { + expect( + accepts({ + kind: "message", + messageId: "msg-1", + role: "user", + parts: [ + { + kind: "file", + file: { mimeType: "application/pdf", bytes: "aGVsbG8=" }, + }, + ], + }), + ).toBe(true) + }) + // `@a2a-js/sdk` v0.3 keys parts on `kind`. `type` is the pre-0.3 spelling. it("rejects a part keyed on type instead of kind", () => { expect(