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..d6b5decd --- /dev/null +++ b/packages/ack-id/src/a2a/schemas/schemas.test.ts @@ -0,0 +1,99 @@ +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 carrying a uri", () => { + expect( + accepts({ + kind: "message", + messageId: "msg-1", + role: "user", + parts: [ + { kind: "file", file: { uri: "https://example.com/receipt.pdf" } }, + ], + }), + ).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( + 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(), })