Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Introduce MessageAttachment sealed interface for type-safe attachments
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/0a17d674-e8bf-4cae-9736-b66b80c5ec7e
  • Loading branch information
Copilot and edburns authored Mar 24, 2026
commit 727e767defbcab1f7656200bfdb1f5081876ef4f
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `CopilotSession.setModel(String, String)` — new overload that accepts an optional reasoning effort level (upstream: [`ea90f07`](https://github.com/github/copilot-sdk/commit/ea90f07))
- `CopilotSession.log(String, String, Boolean, String)` — new overload with an optional `url` parameter (minor addition)
- `BlobAttachment` class — inline base64-encoded binary attachment for messages (e.g., images) (upstream: [`698b259`](https://github.com/github/copilot-sdk/commit/698b259))
- `MessageAttachment` sealed interface — type-safe base for all attachment types (`Attachment`, `BlobAttachment`), with Jackson polymorphic serialization support
- `TelemetryConfig` class — OpenTelemetry configuration for the CLI server; set on `CopilotClientOptions.setTelemetry()` (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0))
- `CopilotClientOptions.setTelemetry(TelemetryConfig)` — enables OpenTelemetry instrumentation in the CLI server (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0))

### Changed

- `MessageOptions.setAttachments(List<?>)` — parameter type widened from `List<Attachment>` to `List<?>` to support both `Attachment` and `BlobAttachment` in the same list
- `SendMessageRequest.setAttachments(List<Object>)` — matching change for the internal request type
- `Attachment` record now implements `MessageAttachment` sealed interface
- `BlobAttachment` class now implements `MessageAttachment` sealed interface and is `final`
- `MessageOptions.setAttachments(List<? extends MessageAttachment>)` — parameter type changed from `List<Attachment>` to `List<? extends MessageAttachment>` to support both `Attachment` and `BlobAttachment` in the same list with full compile-time safety
- `SendMessageRequest.setAttachments(List<MessageAttachment>)` — matching change for the internal request type

### Deprecated

Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/github/copilot/sdk/json/Attachment.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,10 @@
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Attachment(@JsonProperty("type") String type, @JsonProperty("path") String path,
@JsonProperty("displayName") String displayName) {
@JsonProperty("displayName") String displayName) implements MessageAttachment {

@Override
public String getType() {
return type;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* @since 1.2.0
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BlobAttachment {
public final class BlobAttachment implements MessageAttachment {

@JsonProperty("type")
private final String type = "blob";
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/com/github/copilot/sdk/json/MessageAttachment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

package com.github.copilot.sdk.json;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

/**
* Marker interface for all attachment types that can be included in a message.
* <p>
* This is the Java equivalent of the .NET SDK's
* {@code UserMessageDataAttachmentsItem} polymorphic base class.
*
* @see Attachment
* @see BlobAttachment
* @see MessageOptions#setAttachments(java.util.List)
* @since 1.2.0
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({@JsonSubTypes.Type(value = Attachment.class, name = "file"),
@JsonSubTypes.Type(value = BlobAttachment.class, name = "blob")})
public sealed interface MessageAttachment permits Attachment, BlobAttachment {

/**
* Returns the attachment type discriminator (e.g., "file", "blob").
*
* @return the type string
*/
String getType();
}
6 changes: 3 additions & 3 deletions src/main/java/com/github/copilot/sdk/json/MessageOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
public class MessageOptions {

private String prompt;
private List<Object> attachments;
private List<MessageAttachment> attachments;
private String mode;

/**
Expand Down Expand Up @@ -70,7 +70,7 @@ public MessageOptions setPrompt(String prompt) {
*
* @return the list of attachments
*/
public List<Object> getAttachments() {
public List<MessageAttachment> getAttachments() {
return attachments == null ? null : Collections.unmodifiableList(attachments);
}

Expand All @@ -91,7 +91,7 @@ public List<Object> getAttachments() {
* @see Attachment
* @see BlobAttachment
*/
public MessageOptions setAttachments(List<?> attachments) {
public MessageOptions setAttachments(List<? extends MessageAttachment> attachments) {
this.attachments = attachments != null ? new ArrayList<>(attachments) : null;
return this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class SendMessageRequest {
private String prompt;

@JsonProperty("attachments")
private List<Object> attachments;
private List<MessageAttachment> attachments;

@JsonProperty("mode")
private String mode;
Expand All @@ -57,12 +57,12 @@ public void setPrompt(String prompt) {
}

/** Gets the attachments. @return the list of attachments */
public List<Object> getAttachments() {
public List<MessageAttachment> getAttachments() {
return attachments == null ? null : Collections.unmodifiableList(attachments);
}

/** Sets the attachments. @param attachments the list of attachments */
public void setAttachments(List<Object> attachments) {
public void setAttachments(List<MessageAttachment> attachments) {
this.attachments = attachments;
}

Expand Down
16 changes: 16 additions & 0 deletions src/site/markdown/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,22 @@ session.send(new MessageOptions()

See [BlobAttachment](apidocs/com/github/copilot/sdk/json/BlobAttachment.html) Javadoc for details.

Both `Attachment` and `BlobAttachment` implement the sealed `MessageAttachment` interface.
For a mixed list with both types, use an explicit type hint:

```java
session.send(new MessageOptions()
.setPrompt("Analyze these")
.setAttachments(List.<MessageAttachment>of(
new Attachment("file", "/path/to/file.java", "Source"),
new BlobAttachment()
.setData(base64Data)
.setMimeType("image/png")
.setDisplayName("screenshot.png")
))
).get();
```

---

## Bring Your Own Key (BYOK)
Expand Down
158 changes: 158 additions & 0 deletions src/test/java/com/github/copilot/sdk/MessageAttachmentTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

package com.github.copilot.sdk;

import static org.junit.jupiter.api.Assertions.*;

import java.util.List;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.github.copilot.sdk.json.Attachment;
import com.github.copilot.sdk.json.BlobAttachment;
import com.github.copilot.sdk.json.MessageAttachment;
import com.github.copilot.sdk.json.MessageOptions;
import com.github.copilot.sdk.json.SendMessageRequest;

/**
* Tests for the {@link MessageAttachment} sealed interface and type-safe
* attachment handling.
*/
class MessageAttachmentTest {

private static final ObjectMapper MAPPER = new ObjectMapper();

// =========================================================================
// Sealed interface hierarchy
// =========================================================================

@Test
void attachmentImplementsMessageAttachment() {
Attachment attachment = new Attachment("file", "/path/to/file.java", "Source");
assertInstanceOf(MessageAttachment.class, attachment);
assertEquals("file", attachment.getType());
}

@Test
void blobAttachmentImplementsMessageAttachment() {
BlobAttachment blob = new BlobAttachment().setData("aGVsbG8=").setMimeType("image/png")
.setDisplayName("test.png");
assertInstanceOf(MessageAttachment.class, blob);
assertEquals("blob", blob.getType());
}

// =========================================================================
// MessageOptions type safety
// =========================================================================

@Test
void setAttachmentsAcceptsListOfAttachment() {
MessageOptions options = new MessageOptions();
List<Attachment> list = List.of(new Attachment("file", "/a.java", "A"));
options.setAttachments(list);

assertEquals(1, options.getAttachments().size());
assertInstanceOf(Attachment.class, options.getAttachments().get(0));
}

@Test
void setAttachmentsAcceptsListOfBlobAttachment() {
MessageOptions options = new MessageOptions();
List<BlobAttachment> list = List.of(new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/jpeg"));
options.setAttachments(list);

assertEquals(1, options.getAttachments().size());
assertInstanceOf(BlobAttachment.class, options.getAttachments().get(0));
}

@Test
void setAttachmentsAcceptsMixedList() {
MessageOptions options = new MessageOptions();
List<MessageAttachment> mixed = List.of(new Attachment("file", "/a.java", "A"),
new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png"));
options.setAttachments(mixed);

assertEquals(2, options.getAttachments().size());
assertInstanceOf(Attachment.class, options.getAttachments().get(0));
assertInstanceOf(BlobAttachment.class, options.getAttachments().get(1));
}

@Test
void setAttachmentsHandlesNull() {
MessageOptions options = new MessageOptions();
options.setAttachments(null);
assertNull(options.getAttachments());
}

@Test
void getAttachmentsReturnsUnmodifiableList() {
MessageOptions options = new MessageOptions();
options.setAttachments(List.of(new Attachment("file", "/a.java", "A")));
assertThrows(UnsupportedOperationException.class,
() -> options.getAttachments().add(new Attachment("file", "/b.java", "B")));
}

// =========================================================================
// SendMessageRequest type safety
// =========================================================================

@Test
void sendMessageRequestAcceptsMessageAttachmentList() {
SendMessageRequest request = new SendMessageRequest();
List<MessageAttachment> list = List.of(new Attachment("file", "/a.java", "A"),
new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png"));
request.setAttachments(list);

assertEquals(2, request.getAttachments().size());
}

// =========================================================================
// Jackson serialization
// =========================================================================

@Test
void serializeAttachmentIncludesType() throws Exception {
Attachment attachment = new Attachment("file", "/path/to/file.java", "Source");
String json = MAPPER.writeValueAsString(attachment);
assertTrue(json.contains("\"type\":\"file\""));
assertTrue(json.contains("\"path\":\"/path/to/file.java\""));
}

@Test
void serializeBlobAttachmentIncludesType() throws Exception {
BlobAttachment blob = new BlobAttachment().setData("aGVsbG8=").setMimeType("image/png")
.setDisplayName("test.png");
String json = MAPPER.writeValueAsString(blob);
assertTrue(json.contains("\"type\":\"blob\""));
assertTrue(json.contains("\"data\":\"aGVsbG8=\""));
assertTrue(json.contains("\"mimeType\":\"image/png\""));
}

@Test
void serializeMessageOptionsWithMixedAttachments() throws Exception {
MessageOptions options = new MessageOptions().setPrompt("Describe")
.setAttachments(List.of(new Attachment("file", "/a.java", "A"),
new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png").setDisplayName("img.png")));

String json = MAPPER.writeValueAsString(options);
assertTrue(json.contains("\"type\":\"file\""));
assertTrue(json.contains("\"type\":\"blob\""));
}

@Test
void cloneMessageOptionsPreservesAttachments() {
MessageOptions original = new MessageOptions().setPrompt("test")
.setAttachments(List.of(new Attachment("file", "/a.java", "A")));

MessageOptions cloned = original.clone();

assertEquals(1, cloned.getAttachments().size());
assertInstanceOf(Attachment.class, cloned.getAttachments().get(0));
// Verify clone is independent
assertNotSame(original.getAttachments(), cloned.getAttachments());
}
}
Loading