diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 2414093797..62dc7d001b 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -1122,6 +1122,40 @@ public void ToolResultObject_OmitsToolReferences_WhenNull_WithSdkOptions() Assert.False(document.RootElement.TryGetProperty("toolReferences", out _)); } +#pragma warning disable GHCP001 // The queue management surface is intentionally experimental. + [Theory] + [InlineData("message-1")] + [InlineData(null)] + public void QueuePendingItems_MessageId_UsesCamelCaseAndIsOptional(string? messageId) + { + var options = GetSerializerOptions(); + var messageIdProperty = messageId is null ? "" : $""","messageId":"{messageId}" """; + var json = $$""" + { + "id": "queue-1", + "kind": "message", + "displayText": "hello", + "agentMode": "interactive" + {{messageIdProperty}} + } + """; + + var item = JsonSerializer.Deserialize(json, options); + Assert.NotNull(item); + Assert.Equal(messageId, item.MessageId); + + using var document = JsonDocument.Parse(JsonSerializer.Serialize(item, options)); + if (messageId is null) + { + Assert.False(document.RootElement.TryGetProperty("messageId", out _)); + } + else + { + Assert.Equal(messageId, document.RootElement.GetProperty("messageId").GetString()); + } + } +#pragma warning restore GHCP001 + private static JsonSerializerOptions GetSerializerOptions() { var prop = typeof(CopilotClient) diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 8aea505737..25c56838bc 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -48,6 +48,40 @@ public void SessionEvent_Deserializes_AutoTier(AutoTier? expectedTier, string? w } } + [Theory] + [InlineData("message-1")] + [InlineData(null)] + public void UserMessageEvent_MessageId_UsesCamelCaseAndIsOptional(string? messageId) + { + var messageIdProperty = messageId is null ? "" : $""", "messageId": "{messageId}" """; + var json = $$""" + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": "user.message", + "data": { + "content": "hello" + {{messageIdProperty}} + } + } + """; + + var sessionEvent = Assert.IsType(SessionEvent.FromJson(json)); + Assert.Equal(messageId, sessionEvent.Data.MessageId); + + using var document = JsonDocument.Parse(sessionEvent.ToJson()); + var data = document.RootElement.GetProperty("data"); + if (messageId is null) + { + Assert.False(data.TryGetProperty("messageId", out _)); + } + else + { + Assert.Equal(messageId, data.GetProperty("messageId").GetString()); + } + } + public static TheoryData JsonElementBackedEvents => new() { { diff --git a/go/rpc/message_identity_test.go b/go/rpc/message_identity_test.go new file mode 100644 index 0000000000..6124d7084a --- /dev/null +++ b/go/rpc/message_identity_test.go @@ -0,0 +1,101 @@ +package rpc + +import ( + "encoding/json" + "testing" +) + +func TestQueuePendingItemsMessageIDJSONCompatibility(t *testing.T) { + var item QueuePendingItems + if err := json.Unmarshal([]byte(`{ + "id": "queue-1", + "messageId": "message-1", + "kind": "message", + "displayText": "hello", + "agentMode": "interactive" + }`), &item); err != nil { + t.Fatal(err) + } + if item.MessageID == nil || *item.MessageID != "message-1" { + t.Fatalf("MessageID = %v, want message-1", item.MessageID) + } + + encoded, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if got := wire["messageId"]; got != "message-1" { + t.Fatalf("messageId = %v, want message-1", got) + } + + var olderItem QueuePendingItems + if err := json.Unmarshal([]byte(`{ + "id": "queue-2", + "kind": "command", + "displayText": "/help", + "agentMode": "interactive" + }`), &olderItem); err != nil { + t.Fatal(err) + } + if olderItem.MessageID != nil { + t.Fatalf("MessageID = %v, want nil", olderItem.MessageID) + } + + encoded, err = json.Marshal(olderItem) + if err != nil { + t.Fatal(err) + } + wire = nil + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if _, ok := wire["messageId"]; ok { + t.Fatal("messageId should be omitted when absent") + } +} + +func TestUserMessageDataMessageIDJSONCompatibility(t *testing.T) { + var message UserMessageData + if err := json.Unmarshal([]byte(`{"content":"hello","messageId":"message-1"}`), &message); err != nil { + t.Fatal(err) + } + if message.MessageID == nil || *message.MessageID != "message-1" { + t.Fatalf("MessageID = %v, want message-1", message.MessageID) + } + + encoded, err := json.Marshal(message) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if got := wire["messageId"]; got != "message-1" { + t.Fatalf("messageId = %v, want message-1", got) + } + + var olderMessage UserMessageData + if err := json.Unmarshal([]byte(`{"content":"hello"}`), &olderMessage); err != nil { + t.Fatal(err) + } + if olderMessage.MessageID != nil { + t.Fatalf("MessageID = %v, want nil", olderMessage.MessageID) + } + + encoded, err = json.Marshal(olderMessage) + if err != nil { + t.Fatal(err) + } + wire = nil + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if _, ok := wire["messageId"]; ok { + t.Fatal("messageId should be omitted when absent") + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/generated/MessageIdentitySerializationTest.java b/java/sdk/src/test/java/com/github/copilot/generated/MessageIdentitySerializationTest.java new file mode 100644 index 0000000000..3ef4770562 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/generated/MessageIdentitySerializationTest.java @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.generated; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.UserMessageEvent.UserMessageEventData; +import com.github.copilot.generated.rpc.QueuePendingItems; + +class MessageIdentitySerializationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void testQueuePendingMessageIdUsesCamelCaseAndIsOptional() throws Exception { + var item = MAPPER.readValue(""" + { + "id": "queue-1", + "messageId": "message-1", + "kind": "message", + "displayText": "hello", + "agentMode": "interactive" + } + """, QueuePendingItems.class); + + assertEquals("message-1", item.messageId()); + assertEquals("message-1", MAPPER.valueToTree(item).get("messageId").textValue()); + + var olderItem = MAPPER.readValue(""" + { + "id": "queue-2", + "kind": "command", + "displayText": "/help", + "agentMode": "interactive" + } + """, QueuePendingItems.class); + + assertNull(olderItem.messageId()); + assertFalse(MAPPER.valueToTree(olderItem).has("messageId")); + } + + @Test + void testUserMessageIdUsesCamelCaseAndIsOptional() throws Exception { + var message = MAPPER.readValue(""" + {"content": "hello", "messageId": "message-1"} + """, UserMessageEventData.class); + + assertEquals("message-1", message.messageId()); + assertEquals("message-1", MAPPER.valueToTree(message).get("messageId").textValue()); + + var olderMessage = MAPPER.readValue(""" + {"content": "hello"} + """, UserMessageEventData.class); + + assertNull(olderMessage.messageId()); + assertFalse(MAPPER.valueToTree(olderMessage).has("messageId")); + } +} diff --git a/nodejs/test/message-identity-types.test.ts b/nodejs/test/message-identity-types.test.ts new file mode 100644 index 0000000000..903fc7533c --- /dev/null +++ b/nodejs/test/message-identity-types.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { QueuePendingItems } from "../src/generated/rpc.js"; +import type { UserMessageData } from "../src/generated/session-events.js"; + +describe("generated message identity types", () => { + it("exposes optional camelCase message IDs", () => { + const queueItemWithIdentity: QueuePendingItems = { + id: "queue-1", + messageId: "message-1", + kind: "message", + displayText: "hello", + agentMode: "interactive", + }; + const queueItemFromOlderRuntime: QueuePendingItems = { + id: "queue-2", + kind: "command", + displayText: "/help", + agentMode: "interactive", + }; + const userMessageWithIdentity: UserMessageData = { + content: "hello", + messageId: "message-1", + }; + const userMessageFromOlderRuntime: UserMessageData = { + content: "hello", + }; + + expect(queueItemWithIdentity.messageId).toBe("message-1"); + expect(queueItemFromOlderRuntime.messageId).toBeUndefined(); + expect(userMessageWithIdentity.messageId).toBe("message-1"); + expect(userMessageFromOlderRuntime.messageId).toBeUndefined(); + }); +}); diff --git a/python/test_message_identity_generated.py b/python/test_message_identity_generated.py new file mode 100644 index 0000000000..024223ac96 --- /dev/null +++ b/python/test_message_identity_generated.py @@ -0,0 +1,41 @@ +from copilot.generated.rpc import QueuePendingItems +from copilot.generated.session_events import UserMessageData + + +def test_queue_pending_message_id_uses_camel_case_and_is_optional(): + item = QueuePendingItems.from_dict( + { + "id": "queue-1", + "messageId": "message-1", + "kind": "message", + "displayText": "hello", + "agentMode": "interactive", + } + ) + + assert item.message_id == "message-1" + assert item.to_dict()["messageId"] == "message-1" + + older_item = QueuePendingItems.from_dict( + { + "id": "queue-2", + "kind": "command", + "displayText": "/help", + "agentMode": "interactive", + } + ) + + assert older_item.message_id is None + assert "messageId" not in older_item.to_dict() + + +def test_user_message_id_uses_camel_case_and_is_optional(): + message = UserMessageData.from_dict({"content": "hello", "messageId": "message-1"}) + + assert message.message_id == "message-1" + assert message.to_dict()["messageId"] == "message-1" + + older_message = UserMessageData.from_dict({"content": "hello"}) + + assert older_message.message_id is None + assert "messageId" not in older_message.to_dict() diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 8ed40e7c76..942c5dab5b 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -6,7 +6,8 @@ use github_copilot_sdk::AutoTier; use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, - ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, + ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, QueuePendingItems, + QueuePendingItemsKind, SendAgentMode, TasksStartAgentRequest, }; use github_copilot_sdk::session_events::{ PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, @@ -146,6 +147,43 @@ fn permission_event_exposes_managed_approval_required() { assert_eq!(request.managed_approval_required, Some(true)); } +#[test] +fn queue_pending_message_id_uses_camel_case_wire_name() { + let item = QueuePendingItems { + agent_mode: SendAgentMode::Interactive, + display_text: "second message".to_string(), + id: "batch-1".to_string(), + kind: QueuePendingItemsKind::Message, + message_id: Some("message-2".to_string()), + }; + + let serialized = serde_json::to_value(&item).unwrap(); + assert_eq!(serialized["id"], "batch-1"); + assert_eq!(serialized["messageId"], "message-2"); + + let deserialized: QueuePendingItems = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized.message_id.as_deref(), Some("message-2")); +} + +#[test] +fn queue_pending_message_id_is_optional_for_older_hosts() { + let item: QueuePendingItems = serde_json::from_value(serde_json::json!({ + "agentMode": "interactive", + "displayText": "/model gpt-5", + "id": "command-1", + "kind": "command" + })) + .unwrap(); + + assert_eq!(item.message_id, None); + assert!( + serde_json::to_value(item) + .unwrap() + .get("messageId") + .is_none() + ); +} + fn running_extension(id: &str, name: &str) -> Extension { Extension { id: id.to_string(), diff --git a/rust/tests/session_events_test.rs b/rust/tests/session_events_test.rs new file mode 100644 index 0000000000..437be9861f --- /dev/null +++ b/rust/tests/session_events_test.rs @@ -0,0 +1,36 @@ +// Unit tests for generated session-event payloads. + +#![allow(clippy::unwrap_used)] + +use github_copilot_sdk::session_events::UserMessageData; + +#[test] +fn user_message_id_uses_camel_case_wire_name() { + let data = UserMessageData { + content: "queued message".to_string(), + message_id: Some("message-123".to_string()), + ..Default::default() + }; + + let serialized = serde_json::to_value(&data).unwrap(); + assert_eq!(serialized["messageId"], "message-123"); + + let deserialized: UserMessageData = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized.message_id.as_deref(), Some("message-123")); +} + +#[test] +fn user_message_id_is_optional_for_older_hosts() { + let data: UserMessageData = serde_json::from_value(serde_json::json!({ + "content": "legacy message" + })) + .unwrap(); + + assert_eq!(data.message_id, None); + assert!( + serde_json::to_value(data) + .unwrap() + .get("messageId") + .is_none() + ); +}