+ * Explicitly maps only the three protocol fields ({@code mimeType}, {@code name}, {@code bytes})
+ * to and from JSON. This prevents internal implementation fields (such as the lazy-loading
+ * {@code source} or the {@code cachedBytes} soft reference) from leaking into serialized output,
+ * and ensures correct round-trip deserialization via the canonical
+ * {@link FileWithBytes#FileWithBytes(String, String, String)} constructor.
+ */
+ static class FileWithBytesTypeAdapter extends TypeAdapter
diff --git a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java
index ff47e746b..c5199a1e1 100644
--- a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java
+++ b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java
@@ -2,16 +2,22 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.OffsetDateTime;
+import java.util.Base64;
import java.util.List;
import java.util.Map;
import io.a2a.spec.Artifact;
import io.a2a.spec.DataPart;
+import io.a2a.spec.FileContent;
import io.a2a.spec.FilePart;
import io.a2a.spec.FileWithBytes;
import io.a2a.spec.FileWithUri;
@@ -22,6 +28,7 @@
import io.a2a.spec.TaskStatus;
import io.a2a.spec.TextPart;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
/**
* Tests for Task serialization and deserialization using Gson.
@@ -706,4 +713,74 @@ void testTaskWithMixedPartTypes() throws JsonProcessingException {
assertTrue(parts.get(2) instanceof DataPart);
assertTrue(parts.get(3) instanceof FilePart);
}
+
+ // ========== FileContentTypeAdapter tests ==========
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void testFileWithBytesSerializationDoesNotLeakInternalFields() throws Exception {
+ FileWithBytes fwb = new FileWithBytes("application/pdf", "doc.pdf", "base64data");
+
+ String json = JsonUtil.toJson(fwb);
+
+ // Must contain the three protocol fields
+ assertTrue(json.contains("\"mimeType\""), "missing mimeType: " + json);
+ assertTrue(json.contains("\"name\""), "missing name: " + json);
+ assertTrue(json.contains("\"bytes\""), "missing bytes: " + json);
+ // Must NOT contain internal implementation fields
+ assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
+ assertFalse(json.contains("\"cachedBytes\""), "internal cachedBytes field leaked: " + json);
+ }
+
+ @Test
+ void testFileWithBytesRoundTripViaFileContentTypeAdapter() throws Exception {
+ FileWithBytes original = new FileWithBytes("image/png", "photo.png", "abc123");
+
+ String json = JsonUtil.toJson(original);
+ FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);
+
+ assertInstanceOf(FileWithBytes.class, deserialized);
+ FileWithBytes result = (FileWithBytes) deserialized;
+ assertEquals("image/png", result.mimeType());
+ assertEquals("photo.png", result.name());
+ assertEquals("abc123", result.bytes());
+ }
+
+ @Test
+ void testPathBackedFileWithBytesDoesNotLeakFilePath() throws Exception {
+ byte[] content = "hello".getBytes();
+ Path file = tempDir.resolve("secret.txt");
+ Files.write(file, content);
+
+ FileWithBytes fwb = new FileWithBytes("text/plain", file);
+
+ String json = JsonUtil.toJson(fwb);
+
+ // File path must not appear in the serialized JSON
+ assertFalse(json.contains(file.toString()), "file path leaked in JSON: " + json);
+ assertFalse(json.contains(tempDir.toString()), "temp dir path leaked in JSON: " + json);
+ // Must contain the three protocol fields, not internal implementation fields
+ assertTrue(json.contains("\"bytes\""), "missing bytes field: " + json);
+ assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
+ }
+
+ @Test
+ void testPathBackedFileWithBytesRoundTrip() throws Exception {
+ byte[] content = "round-trip".getBytes();
+ Path file = tempDir.resolve("data.bin");
+ Files.write(file, content);
+
+ FileWithBytes original = new FileWithBytes("application/octet-stream", file);
+
+ String json = JsonUtil.toJson(original);
+ FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);
+
+ assertInstanceOf(FileWithBytes.class, deserialized);
+ FileWithBytes result = (FileWithBytes) deserialized;
+ assertEquals("application/octet-stream", result.mimeType());
+ assertEquals("data.bin", result.name());
+ assertEquals(Base64.getEncoder().encodeToString(content), result.bytes());
+ }
}
diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java
index 8704f4699..79748f3c2 100644
--- a/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java
+++ b/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java
@@ -95,8 +95,8 @@ default Part> fromProto(io.a2a.grpc.Part proto) {
} else if (proto.hasRaw()) {
// raw bytes → FilePart(FileWithBytes)
String bytes = Base64.getEncoder().encodeToString(proto.getRaw().toByteArray());
- String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
- String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
+ String mimeType = proto.getMediaType().isEmpty() ? "" : proto.getMediaType();
+ String name = proto.getFilename().isEmpty() ? "" : proto.getFilename();
return new FilePart(new FileWithBytes(mimeType, name, bytes), metadata);
} else if (proto.hasUrl()) {
// url → FilePart(FileWithUri)
diff --git a/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java b/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java
index 8b7eb741b..3daea3a4a 100644
--- a/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java
+++ b/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java
@@ -22,12 +22,19 @@
import io.a2a.spec.FileWithUri;
import io.a2a.spec.Part;
import io.a2a.spec.TextPart;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Base64;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
public class PartTypeAdapterTest {
+ @TempDir
+ Path tempDir;
+
// -------------------------------------------------------------------------
// TextPart
// -------------------------------------------------------------------------
@@ -134,6 +141,36 @@ public void shouldRoundTripFilePartWithBytes() throws JsonProcessingException {
assertEquals("AAEC", bytes.bytes());
}
+ @Test
+ public void shouldRoundTripFilePartWithBytesFromRealFile() throws JsonProcessingException, IOException {
+ // Create a temporary file with some content
+ Path testFile = tempDir.resolve("test-file.txt");
+ String fileContent = "This is test content for lazy loading verification";
+ Files.writeString(testFile, fileContent);
+
+ // Create FileWithBytes from the file path (lazy loading)
+ FileWithBytes fileWithBytes = new FileWithBytes("text/plain", testFile);
+ FilePart original = new FilePart(fileWithBytes);
+
+ // Serialize to JSON (this triggers lazy loading)
+ String json = JsonUtil.toJson(original);
+
+ // Deserialize and verify
+ Part> deserialized = JsonUtil.fromJson(json, Part.class);
+ assertInstanceOf(FilePart.class, deserialized);
+ FilePart result = (FilePart) deserialized;
+ assertInstanceOf(FileWithBytes.class, result.file());
+ FileWithBytes bytes = (FileWithBytes) result.file();
+
+ assertEquals("text/plain", bytes.mimeType());
+ assertEquals("test-file.txt", bytes.name());
+
+ // Verify the content by decoding the base64
+ byte[] decodedBytes = Base64.getDecoder().decode(bytes.bytes());
+ String decodedContent = new String(decodedBytes);
+ assertEquals(fileContent, decodedContent);
+ }
+
// -------------------------------------------------------------------------
// FilePart – FileWithUri
// -------------------------------------------------------------------------
diff --git a/spec/src/main/java/io/a2a/spec/FileWithBytes.java b/spec/src/main/java/io/a2a/spec/FileWithBytes.java
index b5aef3813..53e448c5f 100644
--- a/spec/src/main/java/io/a2a/spec/FileWithBytes.java
+++ b/spec/src/main/java/io/a2a/spec/FileWithBytes.java
@@ -1,28 +1,341 @@
package io.a2a.spec;
+import io.a2a.util.Assert;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.ref.SoftReference;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
/**
* Represents file content embedded directly as base64-encoded bytes.
*
* FileWithBytes is used when file content needs to be transmitted inline with the message or
* artifact, rather than requiring a separate download. This is appropriate for:
*
* The bytes field contains the base64-encoded file content. Decoders should handle the base64
* encoding/decoding transparently.
*
- * This class is immutable.
+ * This class uses lazy loading with soft-reference caching to reduce memory pressure: the
+ * base64-encoded content is computed on-demand and held via a {@link SoftReference}, allowing
+ * the JVM to reclaim it under memory pressure. If reclaimed, it is recomputed on next access.
*
- * @param mimeType the MIME type of the file (e.g., "image/png", "application/pdf") (required)
- * @param name the file name (e.g., "report.pdf", "diagram.png") (required)
- * @param bytes the base64-encoded file content (required)
* @see FileContent
* @see FilePart
* @see FileWithUri
*/
-public record FileWithBytes(String mimeType, String name, String bytes) implements FileContent {
+public final class FileWithBytes implements FileContent {
+
+ /**
+ * Maximum file size that can be loaded (10 MB).
+ * Files larger than this will be rejected at construction time.
+ */
+ private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
+
+ private final String mimeType;
+ private final String name;
+
+ // Source for (re)generating base64 content on-demand
+ private final ByteSource source;
+
+ // Soft-reference cache: held in memory but reclaimable by GC under memory pressure
+ @Nullable
+ private volatile SoftReference
+ * The file is validated at construction time to ensure it exists, is readable, is a regular file,
+ * and does not exceed the maximum size limit ({@value #MAX_FILE_SIZE} bytes).
+ *
+ * The file content is read and base64-encoded on the first call to {@link #bytes()}, then
+ * cached via a soft reference. The cache may be cleared by GC under memory pressure, in
+ * which case the file is re-read on the next access.
+ *
+ * @param mimeType the MIME type of the file (e.g., {@code "image/png"})
+ * @param file the file whose content will be read and encoded
+ * @throws IllegalArgumentException if the file does not exist, is not readable, is not a regular file,
+ * or exceeds the maximum size limit
+ * @throws RuntimeException if an I/O error occurs while checking the file
+ */
+ public FileWithBytes(String mimeType, File file) {
+ this(mimeType, file.toPath());
+ }
+
+ /**
+ * Creates a {@code FileWithBytes} by reading the content of the given {@link Path}.
+ * The file name is derived from {@link Path#getFileName()}.
+ *
+ * The file is validated at construction time to ensure it exists, is readable, is a regular file,
+ * and does not exceed the maximum size limit ({@value #MAX_FILE_SIZE} bytes).
+ *
+ * The file content is read and base64-encoded on the first call to {@link #bytes()}, then
+ * cached via a soft reference. The cache may be cleared by GC under memory pressure, in
+ * which case the file is re-read on the next access.
+ *
+ * @param mimeType the MIME type of the file (e.g., {@code "image/png"})
+ * @param file the path whose content will be read and encoded
+ * @throws IllegalArgumentException if the file does not exist, is not readable, is not a regular file,
+ * or exceeds the maximum size limit
+ * @throws RuntimeException if an I/O error occurs while checking the file
+ */
+ public FileWithBytes(String mimeType, Path file) {
+ this.mimeType = Assert.checkNotNullParam("mimeType", mimeType);
+ validateFile(file);
+ this.name = file.getFileName().toString();
+ this.source = new PathSource(file);
+ }
+
+ /**
+ * Creates a {@code FileWithBytes} by base64-encoding the given raw byte array.
+ *
+ * A defensive copy of {@code content} is made at construction time, so subsequent mutations
+ * to the caller's array have no effect. The copy is base64-encoded on the first call to
+ * {@link #bytes()}, then cached via a soft reference. The cache may be cleared by GC under
+ * memory pressure, in which case the encoding is recomputed from the retained copy.
+ *
+ * @param mimeType the MIME type of the file (e.g., {@code "application/pdf"})
+ * @param name the file name (e.g., {@code "report.pdf"})
+ * @param content the raw file content to be base64-encoded
+ * @throws NullPointerException if {@code content} is null
+ */
+ public FileWithBytes(String mimeType, String name, byte[] content) {
+ this.mimeType = Assert.checkNotNullParam("mimeType", mimeType);
+ this.name = Assert.checkNotNullParam("name", name);
+ this.source = new ByteArraySource(content);
+ }
+
+ @Override
+ public String mimeType() {
+ return mimeType;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the base64-encoded file content.
+ *
+ * The content is computed on the first call and cached via a soft reference. Subsequent calls
+ * return the cached value. If the JVM reclaims the cache under memory pressure, the content is
+ * recomputed transparently on the next access.
+ *
+ * For instances created from a {@link File} or {@link Path}, recomputation involves reading
+ * the file from disk. Callers in performance-sensitive paths should retain the returned value
+ * rather than calling this method repeatedly.
+ *
+ * @return the base64-encoded file content
+ * @throws RuntimeException if an I/O error occurs while reading a file-backed source
+ */
+ public String bytes() {
+ // First check: fast path without locking
+ SoftReference
+ * Important: This method uses identity-based comparison to avoid triggering
+ * potentially expensive I/O operations. Two FileWithBytes instances are considered equal only
+ * if they are the same object (reference equality).
+ *
+ * This design choice prevents:
+ *
+ * If you need to compare the actual content of two FileWithBytes instances, use a separate
+ * method or compare the results of {@link #bytes()} explicitly.
+ *
+ * @param o the object to compare with
+ * @return true if this is the same object as o, false otherwise
+ */
+ @Override
+ public boolean equals(Object o) {
+ return this == o;
+ }
+
+ /**
+ * Returns the identity hash code for this FileWithBytes.
+ *
+ * This method uses {@link System#identityHashCode(Object)} to avoid triggering I/O operations
+ * that would be required to compute a content-based hash code. This ensures that using
+ * FileWithBytes instances as keys in HashMap or elements in HashSet remains safe and efficient.
+ *
+ * @return the identity hash code
+ */
+ @Override
+ public int hashCode() {
+ return System.identityHashCode(this);
+ }
+
+ @Override
+ public String toString() {
+ return "FileWithBytes[mimeType=" + mimeType + ", name=" + name + "]";
+ }
+
+ /**
+ * Validates that a file exists, is readable, is a regular file, and does not exceed the maximum size.
+ *
+ * @param file the file to validate
+ * @throws IllegalArgumentException if validation fails
+ * @throws RuntimeException if an I/O error occurs during validation
+ */
+ private static void validateFile(Path file) {
+ if (!Files.exists(file)) {
+ throw new IllegalArgumentException("File does not exist: " + file);
+ }
+ if (!Files.isReadable(file)) {
+ throw new IllegalArgumentException("File is not readable: " + file);
+ }
+ if (!Files.isRegularFile(file)) {
+ throw new IllegalArgumentException("Not a regular file: " + file);
+ }
+ try {
+ long size = Files.size(file);
+ if (size > MAX_FILE_SIZE) {
+ throw new IllegalArgumentException(
+ String.format("File too large: %d bytes (maximum: %d bytes)", size, MAX_FILE_SIZE)
+ );
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to check file size: " + file, e);
+ }
+ }
+
+ /**
+ * Internal interface for different byte sources.
+ */
+ private interface ByteSource {
+ String getBase64() throws IOException;
+ }
+
+ /**
+ * Source for pre-encoded base64 content.
+ */
+ private static final class PreEncodedSource implements ByteSource {
+ private final String base64;
+
+ PreEncodedSource(String base64) {
+ this.base64 = base64;
+ }
+
+ @Override
+ public String getBase64() {
+ return base64;
+ }
+ }
+
+ /**
+ * Source for file path that needs to be read and encoded.
+ */
+ private static final class PathSource implements ByteSource {
+ private final Path path;
+
+ PathSource(Path path) {
+ this.path = path;
+ }
+
+ @Override
+ public String getBase64() throws IOException {
+ return encodeFileToBase64(path);
+ }
+ }
+
+ /**
+ * Source for byte array that needs to be encoded.
+ */
+ private static final class ByteArraySource implements ByteSource {
+ private final byte[] content;
+
+ ByteArraySource(byte[] content) {
+ this.content = Objects.requireNonNull(content, "content must not be null").clone();
+ }
+
+ @Override
+ public String getBase64() {
+ return Base64.getEncoder().encodeToString(content);
+ }
+ }
+
+ /**
+ * Encodes a file to base64 by streaming its content in chunks.
+ * This avoids loading the entire file into memory at once by using
+ * a wrapping output stream that encodes data as it's written.
+ *
+ * @param path the path to the file to encode
+ * @return the base64-encoded content
+ * @throws IOException if an I/O error occurs reading the file
+ */
+ private static String encodeFileToBase64(Path path) throws IOException {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(path));
+ OutputStream base64OutputStream = Base64.getEncoder().wrap(outputStream)) {
+ byte[] buffer = new byte[8192];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ base64OutputStream.write(buffer, 0, bytesRead);
+ }
+ }
+ return outputStream.toString(StandardCharsets.UTF_8);
+ }
}
diff --git a/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java b/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java
new file mode 100644
index 000000000..5cf556198
--- /dev/null
+++ b/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java
@@ -0,0 +1,273 @@
+package io.a2a.spec;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Unit tests for the convenience constructors added to {@link FileWithBytes}.
+ *
+ * The canonical {@code FileWithBytes(String, String, String)} constructor expects the bytes field
+ * to already be base64-encoded. The constructors under test accept raw sources ({@link java.io.File},
+ * {@link java.nio.file.Path}, or {@code byte[]}) and handle the base64 encoding internally.
+ *
+ * Each group of tests verifies:
+ *
- *
*
+ *
+ *
+ *
+ * The cross-constructor consistency tests confirm that all three convenience constructors and the
+ * canonical constructor produce equivalent {@link FileWithBytes} instances when given the same data.
+ */
+class FileWithBytesTest {
+
+ private static final String SVG_MIME_TYPE = "image/svg+xml";
+ private static final String SVG_RESOURCE = "/a2a-logo-white.svg";
+
+ @TempDir
+ Path tempDir;
+
+ private Path svgPath() throws URISyntaxException {
+ return Path.of(getClass().getResource(SVG_RESOURCE).toURI());
+ }
+
+ private String base64(byte[] content) {
+ return Base64.getEncoder().encodeToString(content);
+ }
+
+ private Path writeTempFile(String name, byte[] content) throws IOException {
+ Path path = tempDir.resolve(name);
+ Files.write(path, content);
+ return path;
+ }
+
+ // ========== File constructor ==========
+
+ @Test
+ void testFileConstructor_encodesContentAsBase64() throws IOException {
+ byte[] content = "hello world".getBytes();
+ File file = writeTempFile("test.txt", content).toFile();
+
+ FileWithBytes fwb = new FileWithBytes("text/plain", file);
+
+ assertEquals("text/plain", fwb.mimeType());
+ assertEquals("test.txt", fwb.name());
+ assertEquals(base64(content), fwb.bytes());
+ }
+
+ @Test
+ void testFileConstructor_useFileNameFromPath() throws IOException, URISyntaxException {
+ File svgFile = svgPath().toFile();
+
+ FileWithBytes fwb = new FileWithBytes(SVG_MIME_TYPE, svgFile);
+
+ assertEquals(SVG_MIME_TYPE, fwb.mimeType());
+ assertEquals("a2a-logo-white.svg", fwb.name());
+ assertEquals(base64(Files.readAllBytes(svgFile.toPath())), fwb.bytes());
+ }
+
+ @Test
+ void testFileConstructor_emptyFile() throws IOException {
+ File file = writeTempFile("empty.bin", new byte[0]).toFile();
+
+ FileWithBytes fwb = new FileWithBytes("application/octet-stream", file);
+
+ assertEquals("application/octet-stream", fwb.mimeType());
+ assertEquals("empty.bin", fwb.name());
+ assertEquals("", fwb.bytes());
+ }
+
+ // ========== Path constructor ==========
+
+ @Test
+ void testPathConstructor_encodesContentAsBase64() throws IOException {
+ byte[] content = "path content".getBytes();
+ Path path = writeTempFile("data.txt", content);
+
+ FileWithBytes fwb = new FileWithBytes("text/plain", path);
+
+ assertEquals("text/plain", fwb.mimeType());
+ assertEquals("data.txt", fwb.name());
+ assertEquals(base64(content), fwb.bytes());
+ }
+
+ @Test
+ void testPathConstructor_usesFileNameFromPath() throws IOException, URISyntaxException {
+ Path path = svgPath();
+
+ FileWithBytes fwb = new FileWithBytes(SVG_MIME_TYPE, path);
+
+ assertEquals(SVG_MIME_TYPE, fwb.mimeType());
+ assertEquals("a2a-logo-white.svg", fwb.name());
+ assertEquals(base64(Files.readAllBytes(path)), fwb.bytes());
+ }
+
+ @Test
+ void testPathConstructor_emptyFile() throws IOException {
+ Path path = writeTempFile("empty.txt", new byte[0]);
+
+ FileWithBytes fwb = new FileWithBytes("text/plain", path);
+
+ assertEquals("text/plain", fwb.mimeType());
+ assertEquals("empty.txt", fwb.name());
+ assertEquals("", fwb.bytes());
+ }
+
+ // ========== byte[] constructor ==========
+
+ @Test
+ void testByteArrayConstructor_encodesContentAsBase64() throws IOException {
+ byte[] content = "binary data".getBytes();
+
+ FileWithBytes fwb = new FileWithBytes("application/octet-stream", "data.bin", content);
+
+ assertEquals("application/octet-stream", fwb.mimeType());
+ assertEquals("data.bin", fwb.name());
+ assertEquals(base64(content), fwb.bytes());
+ }
+
+ @Test
+ void testByteArrayConstructor_emptyArray() throws IOException {
+ FileWithBytes fwb = new FileWithBytes("text/plain", "empty.txt", new byte[0]);
+
+ assertEquals("text/plain", fwb.mimeType());
+ assertEquals("empty.txt", fwb.name());
+ assertEquals("", fwb.bytes());
+ }
+
+ @Test
+ void testByteArrayConstructor_binaryContent() throws IOException {
+ byte[] content = new byte[]{0, 1, 2, (byte) 0xFF, (byte) 0xFE};
+
+ FileWithBytes fwb = new FileWithBytes("application/octet-stream", "bin.dat", content);
+
+ byte[] decoded = Base64.getDecoder().decode(fwb.bytes());
+ assertArrayEquals(content, decoded);
+ }
+
+ // ========== Consistency across constructors ==========
+
+ @Test
+ void testFileAndPathConstructorsProduceSameResult() throws IOException {
+ Path path = writeTempFile("consistent.txt", "consistent content".getBytes());
+
+ FileWithBytes fromFile = new FileWithBytes("text/plain", path.toFile());
+ FileWithBytes fromPath = new FileWithBytes("text/plain", path);
+
+ assertEquals(fromFile.mimeType(), fromPath.mimeType());
+ assertEquals(fromFile.name(), fromPath.name());
+ assertEquals(fromFile.bytes(), fromPath.bytes());
+ }
+
+ @Test
+ void testByteArrayConstructorMatchesCanonicalConstructor() throws IOException {
+ byte[] content = "test".getBytes();
+
+ FileWithBytes fromCanonical = new FileWithBytes("text/plain", "test.txt", base64(content));
+ FileWithBytes fromByteArray = new FileWithBytes("text/plain", "test.txt", content);
+
+ assertEquals(fromCanonical.mimeType(), fromByteArray.mimeType());
+ assertEquals(fromCanonical.name(), fromByteArray.name());
+ assertEquals(fromCanonical.bytes(), fromByteArray.bytes());
+ }
+
+ @Test
+ void testFileConstructorMatchesCanonicalConstructor() throws IOException {
+ byte[] content = "file content".getBytes();
+ Path path = writeTempFile("match.txt", content);
+
+ FileWithBytes fromCanonical = new FileWithBytes("text/plain", "match.txt", base64(content));
+ FileWithBytes fromFile = new FileWithBytes("text/plain", path.toFile());
+
+ assertEquals(fromCanonical.mimeType(), fromFile.mimeType());
+ assertEquals(fromCanonical.name(), fromFile.name());
+ assertEquals(fromCanonical.bytes(), fromFile.bytes());
+ }
+
+ // ========== File validation tests ==========
+
+ @Test
+ void testPathConstructor_rejectsNonExistentFile() {
+ Path nonExistent = tempDir.resolve("does-not-exist.txt");
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> new FileWithBytes("text/plain", nonExistent));
+
+ assertTrue(exception.getMessage().contains("does not exist"));
+ }
+
+ @Test
+ void testPathConstructor_rejectsDirectory() throws IOException {
+ Path directory = tempDir.resolve("subdir");
+ Files.createDirectory(directory);
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> new FileWithBytes("text/plain", directory));
+
+ assertTrue(exception.getMessage().contains("Not a regular file"));
+ }
+
+ @Test
+ void testPathConstructor_rejectsTooLargeFile() throws IOException {
+ // Create a file larger than 10MB
+ Path largeFile = tempDir.resolve("large.bin");
+ byte[] chunk = new byte[1024 * 1024]; // 1MB
+ try (var out = Files.newOutputStream(largeFile)) {
+ for (int i = 0; i < 11; i++) { // Write 11MB
+ out.write(chunk);
+ }
+ }
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> new FileWithBytes("application/octet-stream", largeFile));
+
+ assertTrue(exception.getMessage().contains("too large"));
+ assertTrue(exception.getMessage().contains("maximum"));
+ }
+
+ // ========== Identity-based equality tests ==========
+
+ @Test
+ void testEquals_usesIdentityComparison() throws IOException {
+ byte[] content = "test content".getBytes();
+ Path path = writeTempFile("test.txt", content);
+
+ FileWithBytes fwb1 = new FileWithBytes("text/plain", path);
+ FileWithBytes fwb2 = new FileWithBytes("text/plain", path);
+
+ // Same object should equal itself
+ assertEquals(fwb1, fwb1);
+
+ // Different objects with same content should NOT be equal (identity-based)
+ assertNotEquals(fwb1, fwb2);
+ }
+
+ @Test
+ void testHashCode_usesIdentityHashCode() throws IOException {
+ byte[] content = "test content".getBytes();
+ Path path = writeTempFile("test.txt", content);
+
+ FileWithBytes fwb1 = new FileWithBytes("text/plain", path);
+ FileWithBytes fwb2 = new FileWithBytes("text/plain", path);
+
+ // Hash codes should be different for different objects (identity-based)
+ assertNotEquals(fwb1.hashCode(), fwb2.hashCode());
+
+ // Hash code should be consistent for same object
+ assertEquals(fwb1.hashCode(), fwb1.hashCode());
+ }
+}
diff --git a/spec/src/test/resources/a2a-logo-white.svg b/spec/src/test/resources/a2a-logo-white.svg
new file mode 100644
index 000000000..0d1a0a67a
--- /dev/null
+++ b/spec/src/test/resources/a2a-logo-white.svg
@@ -0,0 +1,9 @@
+