From 8cc3d596c8b987015def2d656f9d10a22ff42c04 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 20 Mar 2026 10:37:30 -0700 Subject: [PATCH] feat: add session.getMetadata to all SDK languages Add a new getSessionMetadata method across all four SDK language bindings (Node.js, Python, Go, .NET) that provides efficient O(1) lookup of a single session's metadata by ID via the session.getMetadata JSON-RPC endpoint. Changes per SDK: - Node.js: getSessionMetadata() in client.ts + skipped E2E test - Python: get_session_metadata() in client.py + running E2E test - Go: GetSessionMetadata() in client.go + types in types.go + running E2E test - .NET: GetSessionMetadataAsync() in Client.cs + skipped E2E test Also adds test/snapshots/session/should_get_session_metadata.yaml for the E2E test replay proxy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 38 +++++++++++++ dotnet/test/SessionTests.cs | 17 ++++++ go/client.go | 32 +++++++++++ go/internal/e2e/session_test.go | 55 +++++++++++++++++++ go/types.go | 10 ++++ nodejs/src/client.ts | 49 +++++++++++++++++ nodejs/test/e2e/session.test.ts | 19 +++++++ python/copilot/client.py | 34 ++++++++++++ python/e2e/test_session.py | 29 ++++++++++ .../session/should_get_session_metadata.yaml | 11 ++++ 10 files changed, 294 insertions(+) create mode 100644 test/snapshots/session/should_get_session_metadata.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 99c0eff00..83f09782c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -840,6 +840,36 @@ public async Task> ListSessionsAsync(SessionListFilter? fi return response.Sessions; } + /// + /// Gets metadata for a specific session by ID. + /// + /// + /// This provides an efficient O(1) lookup of a single session's metadata + /// instead of listing all sessions. + /// + /// The ID of the session to look up. + /// A that can be used to cancel the operation. + /// A task that resolves with the , or null if the session was not found. + /// Thrown when the client is not connected. + /// + /// + /// var metadata = await client.GetSessionMetadataAsync("session-123"); + /// if (metadata != null) + /// { + /// Console.WriteLine($"Session started at: {metadata.StartTime}"); + /// } + /// + /// + public async Task GetSessionMetadataAsync(string sessionId, CancellationToken cancellationToken = default) + { + var connection = await EnsureConnectedAsync(cancellationToken); + + var response = await InvokeRpcAsync( + connection.Rpc, "session.getMetadata", [new GetSessionMetadataRequest(sessionId)], cancellationToken); + + return response.Session; + } + /// /// Gets the ID of the session currently displayed in the TUI. /// @@ -1629,6 +1659,12 @@ internal record ListSessionsRequest( internal record ListSessionsResponse( List Sessions); + internal record GetSessionMetadataRequest( + string SessionId); + + internal record GetSessionMetadataResponse( + SessionMetadata? Session); + internal record UserInputRequestResponse( string Answer, bool WasFreeform); @@ -1735,6 +1771,8 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] + [JsonSerializable(typeof(GetSessionMetadataRequest))] + [JsonSerializable(typeof(GetSessionMetadataResponse))] [JsonSerializable(typeof(PermissionRequestResult))] [JsonSerializable(typeof(PermissionRequestResponseV2))] [JsonSerializable(typeof(ProviderConfig))] diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 5aecaccba..240cb729e 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -405,6 +405,23 @@ public async Task Should_List_Sessions_With_Context() } } + // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle + [Fact(Skip = "Needs test harness CAPI proxy support")] + public async Task Should_Get_Session_Metadata_By_Id() + { + var session = await CreateSessionAsync(); + + var metadata = await Client.GetSessionMetadataAsync(session.SessionId); + Assert.NotNull(metadata); + Assert.Equal(session.SessionId, metadata.SessionId); + Assert.NotEqual(default, metadata.StartTime); + Assert.NotEqual(default, metadata.ModifiedTime); + + // Verify non-existent session returns null + var notFound = await Client.GetSessionMetadataAsync("non-existent-session-id"); + Assert.Null(notFound); + } + [Fact] public async Task SendAndWait_Throws_On_Timeout() { diff --git a/go/client.go b/go/client.go index 22be47ec6..5c3ae5fff 100644 --- a/go/client.go +++ b/go/client.go @@ -775,6 +775,38 @@ func (c *Client) ListSessions(ctx context.Context, filter *SessionListFilter) ([ return response.Sessions, nil } +// GetSessionMetadata returns metadata for a specific session by ID. +// +// This provides an efficient O(1) lookup of a single session's metadata +// instead of listing all sessions. Returns nil if the session is not found. +// +// Example: +// +// metadata, err := client.GetSessionMetadata(context.Background(), "session-123") +// if err != nil { +// log.Fatal(err) +// } +// if metadata != nil { +// fmt.Printf("Session started at: %s\n", metadata.StartTime) +// } +func (c *Client) GetSessionMetadata(ctx context.Context, sessionID string) (*SessionMetadata, error) { + if err := c.ensureConnected(ctx); err != nil { + return nil, err + } + + result, err := c.client.Request("session.getMetadata", getSessionMetadataRequest{SessionID: sessionID}) + if err != nil { + return nil, err + } + + var response getSessionMetadataResponse + if err := json.Unmarshal(result, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal session metadata response: %w", err) + } + + return response.Session, nil +} + // DeleteSession permanently deletes a session and all its data from disk, // including conversation history, planning state, and artifacts. // diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 7f1817da9..1b5967716 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -895,6 +895,61 @@ func TestSession(t *testing.T) { t.Error("Expected error when resuming deleted session") } }) + t.Run("should get session metadata", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Create a session and send a message to persist it + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Small delay to ensure session file is written to disk + time.Sleep(200 * time.Millisecond) + + // Get metadata for the session we just created + metadata, err := client.GetSessionMetadata(t.Context(), session.SessionID) + if err != nil { + t.Fatalf("Failed to get session metadata: %v", err) + } + + if metadata == nil { + t.Fatal("Expected metadata to be non-nil") + } + + if metadata.SessionID != session.SessionID { + t.Errorf("Expected sessionId %s, got %s", session.SessionID, metadata.SessionID) + } + + if metadata.StartTime == "" { + t.Error("Expected startTime to be non-empty") + } + + if metadata.ModifiedTime == "" { + t.Error("Expected modifiedTime to be non-empty") + } + + // Verify context field + if metadata.Context != nil { + if metadata.Context.Cwd == "" { + t.Error("Expected context.Cwd to be non-empty when context is present") + } + } + + // Verify non-existent session returns nil + notFound, err := client.GetSessionMetadata(t.Context(), "non-existent-session-id") + if err != nil { + t.Fatalf("Expected no error for non-existent session, got: %v", err) + } + if notFound != nil { + t.Error("Expected nil metadata for non-existent session") + } + }) t.Run("should get last session id", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index 502d61c1c..f888c9b6e 100644 --- a/go/types.go +++ b/go/types.go @@ -825,6 +825,16 @@ type listSessionsResponse struct { Sessions []SessionMetadata `json:"sessions"` } +// getSessionMetadataRequest is the request for session.getMetadata +type getSessionMetadataRequest struct { + SessionID string `json:"sessionId"` +} + +// getSessionMetadataResponse is the response from session.getMetadata +type getSessionMetadataResponse struct { + Session *SessionMetadata `json:"session,omitempty"` +} + // deleteSessionRequest is the request for session.delete type deleteSessionRequest struct { SessionID string `json:"sessionId"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b8af3dd1..d9e85174b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1074,6 +1074,55 @@ export class CopilotClient { })); } + /** + * Gets metadata for a specific session by ID. + * + * This provides an efficient O(1) lookup of a single session's metadata + * instead of listing all sessions. Returns undefined if the session is not found. + * + * @param sessionId - The ID of the session to look up + * @returns A promise that resolves with the session metadata, or undefined if not found + * @throws Error if the client is not connected + * + * @example + * ```typescript + * const metadata = await client.getSessionMetadata("session-123"); + * if (metadata) { + * console.log(`Session started at: ${metadata.startTime}`); + * } + * ``` + */ + async getSessionMetadata(sessionId: string): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + + const response = await this.connection.sendRequest("session.getMetadata", { sessionId }); + const { session } = response as { + session?: { + sessionId: string; + startTime: string; + modifiedTime: string; + summary?: string; + isRemote: boolean; + context?: SessionContext; + }; + }; + + if (!session) { + return undefined; + } + + return { + sessionId: session.sessionId, + startTime: new Date(session.startTime), + modifiedTime: new Date(session.modifiedTime), + summary: session.summary, + isRemote: session.isRemote, + context: session.context, + }; + } + /** * Gets the foreground session ID in TUI+server mode. * diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index dbcbed8bb..d7733e94b 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -47,6 +47,25 @@ describe("Sessions", async () => { } }); + // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle + it.skip("should get session metadata by ID", { timeout: 60000 }, async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + + // Get metadata for the session we just created + const metadata = await client.getSessionMetadata(session.sessionId); + + expect(metadata).toBeDefined(); + expect(metadata!.sessionId).toBe(session.sessionId); + expect(metadata!.startTime).toBeInstanceOf(Date); + expect(metadata!.modifiedTime).toBeInstanceOf(Date); + expect(typeof metadata!.isRemote).toBe("boolean"); + + // Verify non-existent session returns undefined + const notFound = await client.getSessionMetadata("non-existent-session-id"); + expect(notFound).toBeUndefined(); + }); + it("should have stateful conversation", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); diff --git a/python/copilot/client.py b/python/copilot/client.py index e9dd98d35..38e9ce006 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1060,6 +1060,40 @@ async def list_sessions( sessions_data = response.get("sessions", []) return [SessionMetadata.from_dict(session) for session in sessions_data] + async def get_session_metadata( + self, session_id: str + ) -> "SessionMetadata | None": + """ + Get metadata for a specific session by ID. + + This provides an efficient O(1) lookup of a single session's metadata + instead of listing all sessions. Returns None if the session is not found. + + Args: + session_id: The ID of the session to look up. + + Returns: + A SessionMetadata object, or None if the session was not found. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> metadata = await client.get_session_metadata("session-123") + >>> if metadata: + ... print(f"Session started at: {metadata.startTime}") + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request( + "session.getMetadata", {"sessionId": session_id} + ) + session_data = response.get("session") + if session_data is None: + return None + return SessionMetadata.from_dict(session_data) + async def delete_session(self, session_id: str) -> None: """ Permanently delete a session and all its data from disk, including diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 04f0b448e..7180e6cfe 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -318,6 +318,35 @@ async def test_should_delete_session(self, ctx: E2ETestContext): session_id, on_permission_request=PermissionHandler.approve_all ) + async def test_should_get_session_metadata(self, ctx: E2ETestContext): + import asyncio + + # Create a session and send a message to persist it + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + await session.send_and_wait("Say hello") + + # Small delay to ensure session file is written to disk + await asyncio.sleep(0.2) + + # Get metadata for the session we just created + metadata = await ctx.client.get_session_metadata(session.session_id) + assert metadata is not None + assert metadata.sessionId == session.session_id + assert isinstance(metadata.startTime, str) + assert isinstance(metadata.modifiedTime, str) + assert isinstance(metadata.isRemote, bool) + + # Verify context field is present + if metadata.context is not None: + assert hasattr(metadata.context, "cwd") + assert isinstance(metadata.context.cwd, str) + + # Verify non-existent session returns None + not_found = await ctx.client.get_session_metadata("non-existent-session-id") + assert not_found is None + async def test_should_get_last_session_id(self, ctx: E2ETestContext): import asyncio diff --git a/test/snapshots/session/should_get_session_metadata.yaml b/test/snapshots/session/should_get_session_metadata.yaml new file mode 100644 index 000000000..b326528e1 --- /dev/null +++ b/test/snapshots/session/should_get_session_metadata.yaml @@ -0,0 +1,11 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with your software engineering tasks. What can I assist you + with today?