/** * v2 envelope: a JSON object on the wire carrying a single Message. * * Shape (per parent plan): * { * "messageId": "", * "role": "user" | "agent", * "parts": [...flat Parts...], * "taskId"?: "", * "contextId"?: "", * "metadata"?: {...} * } * * The envelope is just a Message — there is no separate envelope-vs-message * layer in v2 (unlike v0.3 where `{prompt, attachments}` wrapped the user * intent). This collapses naturally because A2A Messages already carry all * fields the envelope used to carry. * * IDs (messageId, taskId) are NOT generated here. Callers pass them in. * Rationale: the adapter has the context to decide ID lifecycle; the SDK * shouldn't surprise callers by minting IDs at serialization time. * * `raw` Part serialization: encoded as a JSON array of byte values, symmetric * with validatePart() which also accepts Uint8Array directly. Base64 was * considered but deferred — v0.4 caps raw at 1MB (larger payloads route * through Object Store in Slice 3), so the ~33% size penalty is bounded. */ import type { Message, Part, Role } from "./types.ts"; export type V2Envelope = Message; export class EnvelopeError extends Error { constructor(message: string, public override cause?: unknown) { super(message); this.name = "EnvelopeError"; } } /** * Parse a v2 envelope from raw NATS message bytes. * * Throws EnvelopeError if: * - bytes are not valid UTF-8 JSON * - the parsed object is missing required fields (messageId, role, parts) * - role is not "user" | "agent" * - parts is not an array * - any Part has zero or >1 variant discriminators (text|data|url|raw) * * Does NOT enforce ULID format on messageId/taskId — those are upstream * conventions, not envelope-layer invariants. */ export function parseV2Envelope(bytes: Uint8Array | string): V2Envelope { let parsed: unknown; try { const text = typeof bytes === "string" ? bytes : new TextDecoder().decode(bytes); parsed = JSON.parse(text); } catch (cause) { throw new EnvelopeError("envelope is not valid UTF-8 JSON", cause); } return validateV2Envelope(parsed); } /** Validate a parsed-but-untyped object as a V2Envelope. Throws on failure. */ export function validateV2Envelope(obj: unknown): V2Envelope { if (typeof obj !== "object" || obj === null) { throw new EnvelopeError("envelope must be a JSON object"); } const e = obj as Record; if (typeof e.messageId !== "string" || !e.messageId) { throw new EnvelopeError("envelope.messageId must be a non-empty string"); } if (e.role !== "user" && e.role !== "agent") { throw new EnvelopeError(`envelope.role must be "user" | "agent", got ${JSON.stringify(e.role)}`); } if (!Array.isArray(e.parts)) { throw new EnvelopeError("envelope.parts must be an array"); } const parts = e.parts.map((p, i) => validatePart(p, i)); const env: V2Envelope = { messageId: e.messageId, role: e.role as Role, parts, }; if (typeof e.taskId === "string") env.taskId = e.taskId; if (typeof e.contextId === "string") env.contextId = e.contextId; if (e.metadata && typeof e.metadata === "object") { env.metadata = e.metadata as Record; } return env; } function validatePart(p: unknown, idx: number): Part { if (typeof p !== "object" || p === null) { throw new EnvelopeError(`parts[${idx}] must be an object`); } const part = p as Record; const variants = ["text", "data", "url", "raw"].filter((k) => part[k] !== undefined); if (variants.length !== 1) { throw new EnvelopeError( `parts[${idx}] must have exactly one of {text,data,url,raw}, got [${variants.join(",")}]` ); } const out: Part = {}; if (typeof part.text === "string") out.text = part.text; if (part.data !== undefined) out.data = part.data; if (typeof part.url === "string") out.url = part.url; if (part.raw !== undefined) { if (part.raw instanceof Uint8Array) { out.raw = part.raw; } else if (Array.isArray(part.raw)) { out.raw = Uint8Array.from(part.raw as number[]); } else { throw new EnvelopeError(`parts[${idx}].raw must be Uint8Array or number[]`); } } if (typeof part.mediaType === "string") out.mediaType = part.mediaType; if (typeof part.filename === "string") out.filename = part.filename; if (part.metadata && typeof part.metadata === "object") { out.metadata = part.metadata as Record; } return out; } /** * Build a v2 envelope. Caller supplies messageId (and optionally taskId/ * contextId); the SDK does not mint IDs. * * Returns the JSON-serialized envelope as Uint8Array, ready for nc.publish(). */ export function buildV2Envelope(args: { messageId: string; role: Role; parts: Part[]; taskId?: string; contextId?: string; metadata?: Record; }): Uint8Array { const env: V2Envelope = { messageId: args.messageId, role: args.role, parts: args.parts, }; if (args.taskId !== undefined) env.taskId = args.taskId; if (args.contextId !== undefined) env.contextId = args.contextId; if (args.metadata !== undefined) env.metadata = args.metadata; // Quick local validation — catches obvious bugs before bytes hit the wire. validateV2Envelope(JSON.parse(JSON.stringify(env, replacer))); return new TextEncoder().encode(JSON.stringify(env, replacer)); } // Symmetric with validatePart() — Uint8Array serializes as an array of bytes. function replacer(_key: string, value: unknown): unknown { if (value instanceof Uint8Array) return Array.from(value); return value; }