Skip to content

Commit 8cc3d59

Browse files
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>
1 parent 005b780 commit 8cc3d59

10 files changed

Lines changed: 294 additions & 0 deletions

File tree

dotnet/src/Client.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,36 @@ public async Task<List<SessionMetadata>> ListSessionsAsync(SessionListFilter? fi
840840
return response.Sessions;
841841
}
842842

843+
/// <summary>
844+
/// Gets metadata for a specific session by ID.
845+
/// </summary>
846+
/// <remarks>
847+
/// This provides an efficient O(1) lookup of a single session's metadata
848+
/// instead of listing all sessions.
849+
/// </remarks>
850+
/// <param name="sessionId">The ID of the session to look up.</param>
851+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
852+
/// <returns>A task that resolves with the <see cref="SessionMetadata"/>, or null if the session was not found.</returns>
853+
/// <exception cref="InvalidOperationException">Thrown when the client is not connected.</exception>
854+
/// <example>
855+
/// <code>
856+
/// var metadata = await client.GetSessionMetadataAsync("session-123");
857+
/// if (metadata != null)
858+
/// {
859+
/// Console.WriteLine($"Session started at: {metadata.StartTime}");
860+
/// }
861+
/// </code>
862+
/// </example>
863+
public async Task<SessionMetadata?> GetSessionMetadataAsync(string sessionId, CancellationToken cancellationToken = default)
864+
{
865+
var connection = await EnsureConnectedAsync(cancellationToken);
866+
867+
var response = await InvokeRpcAsync<GetSessionMetadataResponse>(
868+
connection.Rpc, "session.getMetadata", [new GetSessionMetadataRequest(sessionId)], cancellationToken);
869+
870+
return response.Session;
871+
}
872+
843873
/// <summary>
844874
/// Gets the ID of the session currently displayed in the TUI.
845875
/// </summary>
@@ -1629,6 +1659,12 @@ internal record ListSessionsRequest(
16291659
internal record ListSessionsResponse(
16301660
List<SessionMetadata> Sessions);
16311661

1662+
internal record GetSessionMetadataRequest(
1663+
string SessionId);
1664+
1665+
internal record GetSessionMetadataResponse(
1666+
SessionMetadata? Session);
1667+
16321668
internal record UserInputRequestResponse(
16331669
string Answer,
16341670
bool WasFreeform);
@@ -1735,6 +1771,8 @@ private static LogLevel MapLevel(TraceEventType eventType)
17351771
[JsonSerializable(typeof(HooksInvokeResponse))]
17361772
[JsonSerializable(typeof(ListSessionsRequest))]
17371773
[JsonSerializable(typeof(ListSessionsResponse))]
1774+
[JsonSerializable(typeof(GetSessionMetadataRequest))]
1775+
[JsonSerializable(typeof(GetSessionMetadataResponse))]
17381776
[JsonSerializable(typeof(PermissionRequestResult))]
17391777
[JsonSerializable(typeof(PermissionRequestResponseV2))]
17401778
[JsonSerializable(typeof(ProviderConfig))]

dotnet/test/SessionTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,23 @@ public async Task Should_List_Sessions_With_Context()
405405
}
406406
}
407407

408+
// TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle
409+
[Fact(Skip = "Needs test harness CAPI proxy support")]
410+
public async Task Should_Get_Session_Metadata_By_Id()
411+
{
412+
var session = await CreateSessionAsync();
413+
414+
var metadata = await Client.GetSessionMetadataAsync(session.SessionId);
415+
Assert.NotNull(metadata);
416+
Assert.Equal(session.SessionId, metadata.SessionId);
417+
Assert.NotEqual(default, metadata.StartTime);
418+
Assert.NotEqual(default, metadata.ModifiedTime);
419+
420+
// Verify non-existent session returns null
421+
var notFound = await Client.GetSessionMetadataAsync("non-existent-session-id");
422+
Assert.Null(notFound);
423+
}
424+
408425
[Fact]
409426
public async Task SendAndWait_Throws_On_Timeout()
410427
{

go/client.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,38 @@ func (c *Client) ListSessions(ctx context.Context, filter *SessionListFilter) ([
775775
return response.Sessions, nil
776776
}
777777

778+
// GetSessionMetadata returns metadata for a specific session by ID.
779+
//
780+
// This provides an efficient O(1) lookup of a single session's metadata
781+
// instead of listing all sessions. Returns nil if the session is not found.
782+
//
783+
// Example:
784+
//
785+
// metadata, err := client.GetSessionMetadata(context.Background(), "session-123")
786+
// if err != nil {
787+
// log.Fatal(err)
788+
// }
789+
// if metadata != nil {
790+
// fmt.Printf("Session started at: %s\n", metadata.StartTime)
791+
// }
792+
func (c *Client) GetSessionMetadata(ctx context.Context, sessionID string) (*SessionMetadata, error) {
793+
if err := c.ensureConnected(ctx); err != nil {
794+
return nil, err
795+
}
796+
797+
result, err := c.client.Request("session.getMetadata", getSessionMetadataRequest{SessionID: sessionID})
798+
if err != nil {
799+
return nil, err
800+
}
801+
802+
var response getSessionMetadataResponse
803+
if err := json.Unmarshal(result, &response); err != nil {
804+
return nil, fmt.Errorf("failed to unmarshal session metadata response: %w", err)
805+
}
806+
807+
return response.Session, nil
808+
}
809+
778810
// DeleteSession permanently deletes a session and all its data from disk,
779811
// including conversation history, planning state, and artifacts.
780812
//

go/internal/e2e/session_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,61 @@ func TestSession(t *testing.T) {
895895
t.Error("Expected error when resuming deleted session")
896896
}
897897
})
898+
t.Run("should get session metadata", func(t *testing.T) {
899+
ctx.ConfigureForTest(t)
900+
901+
// Create a session and send a message to persist it
902+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll})
903+
if err != nil {
904+
t.Fatalf("Failed to create session: %v", err)
905+
}
906+
907+
_, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say hello"})
908+
if err != nil {
909+
t.Fatalf("Failed to send message: %v", err)
910+
}
911+
912+
// Small delay to ensure session file is written to disk
913+
time.Sleep(200 * time.Millisecond)
914+
915+
// Get metadata for the session we just created
916+
metadata, err := client.GetSessionMetadata(t.Context(), session.SessionID)
917+
if err != nil {
918+
t.Fatalf("Failed to get session metadata: %v", err)
919+
}
920+
921+
if metadata == nil {
922+
t.Fatal("Expected metadata to be non-nil")
923+
}
924+
925+
if metadata.SessionID != session.SessionID {
926+
t.Errorf("Expected sessionId %s, got %s", session.SessionID, metadata.SessionID)
927+
}
928+
929+
if metadata.StartTime == "" {
930+
t.Error("Expected startTime to be non-empty")
931+
}
932+
933+
if metadata.ModifiedTime == "" {
934+
t.Error("Expected modifiedTime to be non-empty")
935+
}
936+
937+
// Verify context field
938+
if metadata.Context != nil {
939+
if metadata.Context.Cwd == "" {
940+
t.Error("Expected context.Cwd to be non-empty when context is present")
941+
}
942+
}
943+
944+
// Verify non-existent session returns nil
945+
notFound, err := client.GetSessionMetadata(t.Context(), "non-existent-session-id")
946+
if err != nil {
947+
t.Fatalf("Expected no error for non-existent session, got: %v", err)
948+
}
949+
if notFound != nil {
950+
t.Error("Expected nil metadata for non-existent session")
951+
}
952+
})
898953
t.Run("should get last session id", func(t *testing.T) {
899954
ctx.ConfigureForTest(t)
900955

go/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,16 @@ type listSessionsResponse struct {
825825
Sessions []SessionMetadata `json:"sessions"`
826826
}
827827

828+
// getSessionMetadataRequest is the request for session.getMetadata
829+
type getSessionMetadataRequest struct {
830+
SessionID string `json:"sessionId"`
831+
}
832+
833+
// getSessionMetadataResponse is the response from session.getMetadata
834+
type getSessionMetadataResponse struct {
835+
Session *SessionMetadata `json:"session,omitempty"`
836+
}
837+
828838
// deleteSessionRequest is the request for session.delete
829839
type deleteSessionRequest struct {
830840
SessionID string `json:"sessionId"`

nodejs/src/client.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,55 @@ export class CopilotClient {
10741074
}));
10751075
}
10761076

1077+
/**
1078+
* Gets metadata for a specific session by ID.
1079+
*
1080+
* This provides an efficient O(1) lookup of a single session's metadata
1081+
* instead of listing all sessions. Returns undefined if the session is not found.
1082+
*
1083+
* @param sessionId - The ID of the session to look up
1084+
* @returns A promise that resolves with the session metadata, or undefined if not found
1085+
* @throws Error if the client is not connected
1086+
*
1087+
* @example
1088+
* ```typescript
1089+
* const metadata = await client.getSessionMetadata("session-123");
1090+
* if (metadata) {
1091+
* console.log(`Session started at: ${metadata.startTime}`);
1092+
* }
1093+
* ```
1094+
*/
1095+
async getSessionMetadata(sessionId: string): Promise<SessionMetadata | undefined> {
1096+
if (!this.connection) {
1097+
throw new Error("Client not connected");
1098+
}
1099+
1100+
const response = await this.connection.sendRequest("session.getMetadata", { sessionId });
1101+
const { session } = response as {
1102+
session?: {
1103+
sessionId: string;
1104+
startTime: string;
1105+
modifiedTime: string;
1106+
summary?: string;
1107+
isRemote: boolean;
1108+
context?: SessionContext;
1109+
};
1110+
};
1111+
1112+
if (!session) {
1113+
return undefined;
1114+
}
1115+
1116+
return {
1117+
sessionId: session.sessionId,
1118+
startTime: new Date(session.startTime),
1119+
modifiedTime: new Date(session.modifiedTime),
1120+
summary: session.summary,
1121+
isRemote: session.isRemote,
1122+
context: session.context,
1123+
};
1124+
}
1125+
10771126
/**
10781127
* Gets the foreground session ID in TUI+server mode.
10791128
*

nodejs/test/e2e/session.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ describe("Sessions", async () => {
4747
}
4848
});
4949

50+
// TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle
51+
it.skip("should get session metadata by ID", { timeout: 60000 }, async () => {
52+
const session = await client.createSession({ onPermissionRequest: approveAll });
53+
expect(session.sessionId).toMatch(/^[a-f0-9-]+$/);
54+
55+
// Get metadata for the session we just created
56+
const metadata = await client.getSessionMetadata(session.sessionId);
57+
58+
expect(metadata).toBeDefined();
59+
expect(metadata!.sessionId).toBe(session.sessionId);
60+
expect(metadata!.startTime).toBeInstanceOf(Date);
61+
expect(metadata!.modifiedTime).toBeInstanceOf(Date);
62+
expect(typeof metadata!.isRemote).toBe("boolean");
63+
64+
// Verify non-existent session returns undefined
65+
const notFound = await client.getSessionMetadata("non-existent-session-id");
66+
expect(notFound).toBeUndefined();
67+
});
68+
5069
it("should have stateful conversation", async () => {
5170
const session = await client.createSession({ onPermissionRequest: approveAll });
5271
const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" });

python/copilot/client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,40 @@ async def list_sessions(
10601060
sessions_data = response.get("sessions", [])
10611061
return [SessionMetadata.from_dict(session) for session in sessions_data]
10621062

1063+
async def get_session_metadata(
1064+
self, session_id: str
1065+
) -> "SessionMetadata | None":
1066+
"""
1067+
Get metadata for a specific session by ID.
1068+
1069+
This provides an efficient O(1) lookup of a single session's metadata
1070+
instead of listing all sessions. Returns None if the session is not found.
1071+
1072+
Args:
1073+
session_id: The ID of the session to look up.
1074+
1075+
Returns:
1076+
A SessionMetadata object, or None if the session was not found.
1077+
1078+
Raises:
1079+
RuntimeError: If the client is not connected.
1080+
1081+
Example:
1082+
>>> metadata = await client.get_session_metadata("session-123")
1083+
>>> if metadata:
1084+
... print(f"Session started at: {metadata.startTime}")
1085+
"""
1086+
if not self._client:
1087+
raise RuntimeError("Client not connected")
1088+
1089+
response = await self._client.request(
1090+
"session.getMetadata", {"sessionId": session_id}
1091+
)
1092+
session_data = response.get("session")
1093+
if session_data is None:
1094+
return None
1095+
return SessionMetadata.from_dict(session_data)
1096+
10631097
async def delete_session(self, session_id: str) -> None:
10641098
"""
10651099
Permanently delete a session and all its data from disk, including

python/e2e/test_session.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,35 @@ async def test_should_delete_session(self, ctx: E2ETestContext):
318318
session_id, on_permission_request=PermissionHandler.approve_all
319319
)
320320

321+
async def test_should_get_session_metadata(self, ctx: E2ETestContext):
322+
import asyncio
323+
324+
# Create a session and send a message to persist it
325+
session = await ctx.client.create_session(
326+
on_permission_request=PermissionHandler.approve_all
327+
)
328+
await session.send_and_wait("Say hello")
329+
330+
# Small delay to ensure session file is written to disk
331+
await asyncio.sleep(0.2)
332+
333+
# Get metadata for the session we just created
334+
metadata = await ctx.client.get_session_metadata(session.session_id)
335+
assert metadata is not None
336+
assert metadata.sessionId == session.session_id
337+
assert isinstance(metadata.startTime, str)
338+
assert isinstance(metadata.modifiedTime, str)
339+
assert isinstance(metadata.isRemote, bool)
340+
341+
# Verify context field is present
342+
if metadata.context is not None:
343+
assert hasattr(metadata.context, "cwd")
344+
assert isinstance(metadata.context.cwd, str)
345+
346+
# Verify non-existent session returns None
347+
not_found = await ctx.client.get_session_metadata("non-existent-session-id")
348+
assert not_found is None
349+
321350
async def test_should_get_last_session_id(self, ctx: E2ETestContext):
322351
import asyncio
323352

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: Say hello
9+
- role: assistant
10+
content: Hello! I'm GitHub Copilot CLI, ready to help you with your software engineering tasks. What can I assist you
11+
with today?

0 commit comments

Comments
 (0)