diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e46a7a888..4bd5b5b93 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2766,6 +2766,10 @@ public sealed class SessionMetadata /// public string? Summary { get; set; } /// + /// Identifier of the client driving the session. + /// + public string? ClientName { get; set; } + /// /// Whether the session is running on a remote server. /// public bool IsRemote { get; set; } diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index c1e406104..72685b1f2 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -171,6 +171,30 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } + [Fact] + public void SessionMetadata_CanRoundTripClientName_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new SessionMetadata + { + SessionId = "session-1", + StartTime = DateTimeOffset.Parse("2025-01-01T00:00:00Z"), + ModifiedTime = DateTimeOffset.Parse("2025-01-01T01:00:00Z"), + Summary = "loaded session", + ClientName = "my-app", + IsRemote = false + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal("my-app", root.GetProperty("clientName").GetString()); + + var deserialized = JsonSerializer.Deserialize(json, options); + Assert.NotNull(deserialized); + Assert.Equal("my-app", deserialized.ClientName); + } + [Fact] public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() { diff --git a/go/client_test.go b/go/client_test.go index 39358a72a..efd41146b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -402,6 +402,33 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } +func TestSessionMetadata_ClientName(t *testing.T) { + t.Run("round-trips clientName in JSON", func(t *testing.T) { + var metadata SessionMetadata + if err := json.Unmarshal([]byte(`{ + "sessionId":"s1", + "startTime":"2025-01-01T00:00:00Z", + "modifiedTime":"2025-01-01T01:00:00Z", + "summary":"loaded session", + "clientName":"my-app", + "isRemote":false + }`), &metadata); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if metadata.ClientName == nil || *metadata.ClientName != "my-app" { + t.Fatalf("Expected clientName to be my-app, got %v", metadata.ClientName) + } + + data, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + if !strings.Contains(string(data), `"clientName":"my-app"`) { + t.Fatalf("Expected marshaled JSON to include clientName, got %s", string(data)) + } + }) +} + func TestCreateSessionRequest_Agent(t *testing.T) { t.Run("includes agent in JSON when set", func(t *testing.T) { req := createSessionRequest{Agent: "test-agent"} diff --git a/go/types.go b/go/types.go index 52fd27eee..8144659e9 100644 --- a/go/types.go +++ b/go/types.go @@ -1426,6 +1426,7 @@ type SessionMetadata struct { StartTime time.Time `json:"startTime"` ModifiedTime time.Time `json:"modifiedTime"` Summary *string `json:"summary,omitempty"` + ClientName *string `json:"clientName,omitempty"` IsRemote bool `json:"isRemote"` Context *SessionContext `json:"context,omitempty"` } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionMetadata.java b/java/src/main/java/com/github/copilot/rpc/SessionMetadata.java index 90207b9c7..e18163e77 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionMetadata.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionMetadata.java @@ -46,6 +46,9 @@ public class SessionMetadata { @JsonProperty("summary") private String summary; + @JsonProperty("clientName") + private String clientName; + @JsonProperty("isRemote") private boolean isRemote; @@ -130,6 +133,25 @@ public void setSummary(String summary) { this.summary = summary; } + /** + * Gets the identifier of the client driving the session. + * + * @return the client name, or {@code null} if not available + */ + public String getClientName() { + return clientName; + } + + /** + * Sets the client identifier for the session. + * + * @param clientName + * the client name + */ + public void setClientName(String clientName) { + this.clientName = clientName; + } + /** * Returns whether this session is stored remotely. * diff --git a/java/src/test/java/com/github/copilot/ModelInfoTest.java b/java/src/test/java/com/github/copilot/ModelInfoTest.java index b4936d1cc..e9252cb8f 100644 --- a/java/src/test/java/com/github/copilot/ModelInfoTest.java +++ b/java/src/test/java/com/github/copilot/ModelInfoTest.java @@ -60,8 +60,11 @@ void sessionMetadataGettersAndSetters() { assertNull(meta.getStartTime()); assertNull(meta.getModifiedTime()); assertNull(meta.getSummary()); + assertNull(meta.getClientName()); assertFalse(meta.isRemote()); + meta.setClientName("my-app"); + assertEquals("my-app", meta.getClientName()); meta.setRemote(true); assertTrue(meta.isRemote()); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 11e6131cb..d9913aee8 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1316,6 +1316,7 @@ export class CopilotClient { startTime: string; modifiedTime: string; summary?: string; + clientName?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }>; @@ -1354,6 +1355,7 @@ export class CopilotClient { startTime: string; modifiedTime: string; summary?: string; + clientName?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }; @@ -1371,6 +1373,7 @@ export class CopilotClient { startTime: string; modifiedTime: string; summary?: string; + clientName?: string; isRemote: boolean; context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }): SessionMetadata { @@ -1380,6 +1383,7 @@ export class CopilotClient { startTime: new Date(raw.startTime), modifiedTime: new Date(raw.modifiedTime), summary: raw.summary, + clientName: raw.clientName, isRemote: raw.isRemote, context: context ? { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 333079e1f..dab04de86 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2033,6 +2033,8 @@ export interface SessionMetadata { startTime: Date; modifiedTime: Date; summary?: string; + /** Identifier of the client driving the session */ + clientName?: string; isRemote: boolean; /** Working directory context (working directory, git info) from session creation */ context?: SessionContext; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a4554550e..77fd4f2a1 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -240,6 +240,80 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("maps clientName from session.list responses", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string) => { + if (method === "session.list") { + return { + sessions: [ + { + sessionId: "session-1", + startTime: "2025-01-01T00:00:00Z", + modifiedTime: "2025-01-01T01:00:00Z", + summary: "test session", + clientName: "my-app", + isRemote: false, + }, + ], + }; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + + const sessions = await client.listSessions(); + + expect(sessions).toEqual([ + expect.objectContaining({ + sessionId: "session-1", + clientName: "my-app", + summary: "test session", + }), + ]); + expect(sessions[0].startTime).toBeInstanceOf(Date); + expect(sessions[0].modifiedTime).toBeInstanceOf(Date); + }); + + it("maps clientName from session.getMetadata responses", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string) => { + if (method === "session.getMetadata") { + return { + session: { + sessionId: "session-1", + startTime: "2025-01-01T00:00:00Z", + modifiedTime: "2025-01-01T01:00:00Z", + summary: "loaded session", + clientName: "my-app", + isRemote: false, + }, + }; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + + const metadata = await client.getSessionMetadata("session-1"); + + expect(metadata).toEqual( + expect.objectContaining({ + sessionId: "session-1", + clientName: "my-app", + summary: "loaded session", + }) + ); + expect(metadata?.startTime).toBeInstanceOf(Date); + expect(metadata?.modifiedTime).toBeInstanceOf(Date); + }); + it("forwards enableSessionTelemetry in session.create request", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 4386adb08..dd56feb50 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -780,6 +780,7 @@ class SessionMetadata: modified_time: datetime # Timestamp when session was last modified is_remote: bool # Whether the session is remote summary: str | None = None # Optional summary of the session + client_name: str | None = None # Identifier of the client driving the session context: SessionContext | None = None # Working directory context @staticmethod @@ -795,6 +796,7 @@ def from_dict(obj: Any) -> SessionMetadata: f"startTime={start_time}, modifiedTime={modified_time}, isRemote={is_remote}" ) summary = obj.get("summary") + client_name = obj.get("clientName") context_dict = obj.get("context") context = SessionContext.from_dict(context_dict) if context_dict else None return SessionMetadata( @@ -803,6 +805,7 @@ def from_dict(obj: Any) -> SessionMetadata: modified_time=_parse_session_timestamp(modified_time), is_remote=bool(is_remote), summary=summary, + client_name=client_name, context=context, ) @@ -814,6 +817,8 @@ def to_dict(self) -> dict: result["isRemote"] = self.is_remote if self.summary is not None: result["summary"] = self.summary + if self.client_name is not None: + result["clientName"] = self.client_name if self.context is not None: result["context"] = self.context.to_dict() return result diff --git a/python/test_client.py b/python/test_client.py index 757322fa2..634fc64ea 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -22,6 +22,7 @@ ModelInfo, ModelLimits, ModelSupports, + SessionMetadata, ) from copilot.session import PermissionHandler from e2e.testharness import CLI_PATH @@ -602,6 +603,23 @@ async def mock_request(method, params): finally: await client.force_stop() + +class TestSessionMetadataParsing: + def test_session_metadata_round_trips_client_name(self): + metadata = SessionMetadata.from_dict( + { + "sessionId": "session-1", + "startTime": "2025-01-01T00:00:00Z", + "modifiedTime": "2025-01-01T01:00:00Z", + "summary": "loaded session", + "clientName": "my-app", + "isRemote": False, + } + ) + + assert metadata.client_name == "my-app" + assert metadata.to_dict()["clientName"] == "my-app" + @pytest.mark.asyncio async def test_create_session_forwards_provider_headers(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index 6f5c826c6..7d863e49a 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3362,6 +3362,9 @@ pub struct SessionMetadata { /// Agent-generated session summary. #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + /// Identifier of the client driving the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, /// Whether the session is running remotely. pub is_remote: bool, } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index bb4e602e0..e4c7cc4bf 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -597,6 +597,7 @@ async fn list_sessions_returns_typed_metadata() { "startTime": "2025-01-01T00:00:00Z", "modifiedTime": "2025-01-01T01:00:00Z", "summary": "test session", + "clientName": "my-app", "isRemote": false, }] }, @@ -607,6 +608,7 @@ async fn list_sessions_returns_typed_metadata() { assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].session_id, "s1"); assert_eq!(sessions[0].summary, Some("test session".to_string())); + assert_eq!(sessions[0].client_name.as_deref(), Some("my-app")); } #[tokio::test] @@ -1100,6 +1102,7 @@ async fn get_session_metadata_returns_typed_metadata() { "startTime": "2025-01-01T00:00:00Z", "modifiedTime": "2025-01-01T01:00:00Z", "summary": "loaded session", + "clientName": "my-app", "isRemote": false, } }, @@ -1110,6 +1113,7 @@ async fn get_session_metadata_returns_typed_metadata() { let metadata = metadata.expect("server returned a session"); assert_eq!(metadata.session_id, "s1"); assert_eq!(metadata.summary.as_deref(), Some("loaded session")); + assert_eq!(metadata.client_name.as_deref(), Some("my-app")); } #[tokio::test]