Skip to content

Commit 4c476fd

Browse files
feat: add session.getMetadata to all SDK languages (#899)
* 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> * Fix CI failures: Python formatting and Node.js session test assertion - Fix Python copilot/client.py ruff format check by collapsing get_session_metadata method signature and request call to single lines - Fix Node.js session.test.ts assertion to check first message only, since CLI 1.0.11 now emits session.custom_agents_updated after session.start (matching the same fix needed on main) * refactor: extract toSessionMetadata helper and fix Python type annotation * fix: handle custom-agents endpoint in replay proxy for CLI 1.0.11 CLI 1.0.11 makes a GET request to /agents/*/custom-agents/* during startup. The replay proxy had no handler for this endpoint, causing it to call onError and hang new CLI processes. This broke the 'should resume a session using a new client' and 'should produce deltas after session resume' E2E tests which spawn a second CopilotClient. Add a stub handler (returning empty agents list) matching the existing pattern used for memory endpoints. * remove redundant custom-agents handler (superseded by main's generic 404 fallback) * Unskip getSessionMetadata E2E tests (CLI 1.0.12-0 adds support) Now that the runtime includes session.getMetadata, enable the previously-skipped Node.js and .NET E2E tests. Both tests are updated to send a message and wait before querying metadata, matching the pattern used in the already-running Python and Go tests (session files aren't persisted until at least one exchange completes). * Add snapshot for Node.js/.NET getSessionMetadata E2E tests The test harnesses derive the snapshot filename from the test name. Node.js and .NET use 'should get session metadata by ID' which maps to should_get_session_metadata_by_id.yaml, while Python/Go use a slightly different name. Add the matching snapshot so the replay proxy can serve responses in CI. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c9b998b commit 4c476fd

File tree

11 files changed

+319
-8
lines changed

11 files changed

+319
-8
lines changed

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>
@@ -1633,6 +1663,12 @@ internal record ListSessionsRequest(
16331663
internal record ListSessionsResponse(
16341664
List<SessionMetadata> Sessions);
16351665

1666+
internal record GetSessionMetadataRequest(
1667+
string SessionId);
1668+
1669+
internal record GetSessionMetadataResponse(
1670+
SessionMetadata? Session);
1671+
16361672
internal record UserInputRequestResponse(
16371673
string Answer,
16381674
bool WasFreeform);
@@ -1739,6 +1775,8 @@ private static LogLevel MapLevel(TraceEventType eventType)
17391775
[JsonSerializable(typeof(HooksInvokeResponse))]
17401776
[JsonSerializable(typeof(ListSessionsRequest))]
17411777
[JsonSerializable(typeof(ListSessionsResponse))]
1778+
[JsonSerializable(typeof(GetSessionMetadataRequest))]
1779+
[JsonSerializable(typeof(GetSessionMetadataResponse))]
17421780
[JsonSerializable(typeof(PermissionRequestResult))]
17431781
[JsonSerializable(typeof(PermissionRequestResponseV2))]
17441782
[JsonSerializable(typeof(ProviderConfig))]

dotnet/test/SessionTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,26 @@ public async Task Should_List_Sessions_With_Context()
407407
}
408408
}
409409

410+
[Fact]
411+
public async Task Should_Get_Session_Metadata_By_Id()
412+
{
413+
var session = await CreateSessionAsync();
414+
415+
// Send a message to persist the session to disk
416+
await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello" });
417+
await Task.Delay(200);
418+
419+
var metadata = await Client.GetSessionMetadataAsync(session.SessionId);
420+
Assert.NotNull(metadata);
421+
Assert.Equal(session.SessionId, metadata.SessionId);
422+
Assert.NotEqual(default, metadata.StartTime);
423+
Assert.NotEqual(default, metadata.ModifiedTime);
424+
425+
// Verify non-existent session returns null
426+
var notFound = await Client.GetSessionMetadataAsync("non-existent-session-id");
427+
Assert.Null(notFound);
428+
}
429+
410430
[Fact]
411431
public async Task SendAndWait_Throws_On_Timeout()
412432
{

go/client.go

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

792+
// GetSessionMetadata returns metadata for a specific session by ID.
793+
//
794+
// This provides an efficient O(1) lookup of a single session's metadata
795+
// instead of listing all sessions. Returns nil if the session is not found.
796+
//
797+
// Example:
798+
//
799+
// metadata, err := client.GetSessionMetadata(context.Background(), "session-123")
800+
// if err != nil {
801+
// log.Fatal(err)
802+
// }
803+
// if metadata != nil {
804+
// fmt.Printf("Session started at: %s\n", metadata.StartTime)
805+
// }
806+
func (c *Client) GetSessionMetadata(ctx context.Context, sessionID string) (*SessionMetadata, error) {
807+
if err := c.ensureConnected(ctx); err != nil {
808+
return nil, err
809+
}
810+
811+
result, err := c.client.Request("session.getMetadata", getSessionMetadataRequest{SessionID: sessionID})
812+
if err != nil {
813+
return nil, err
814+
}
815+
816+
var response getSessionMetadataResponse
817+
if err := json.Unmarshal(result, &response); err != nil {
818+
return nil, fmt.Errorf("failed to unmarshal session metadata response: %w", err)
819+
}
820+
821+
return response.Session, nil
822+
}
823+
792824
// DeleteSession permanently deletes a session and all its data from disk,
793825
// including conversation history, planning state, and artifacts.
794826
//

go/internal/e2e/session_test.go

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

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: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,14 +1081,67 @@ export class CopilotClient {
10811081
}>;
10821082
};
10831083

1084-
return sessions.map((s) => ({
1085-
sessionId: s.sessionId,
1086-
startTime: new Date(s.startTime),
1087-
modifiedTime: new Date(s.modifiedTime),
1088-
summary: s.summary,
1089-
isRemote: s.isRemote,
1090-
context: s.context,
1091-
}));
1084+
return sessions.map(CopilotClient.toSessionMetadata);
1085+
}
1086+
1087+
/**
1088+
* Gets metadata for a specific session by ID.
1089+
*
1090+
* This provides an efficient O(1) lookup of a single session's metadata
1091+
* instead of listing all sessions. Returns undefined if the session is not found.
1092+
*
1093+
* @param sessionId - The ID of the session to look up
1094+
* @returns A promise that resolves with the session metadata, or undefined if not found
1095+
* @throws Error if the client is not connected
1096+
*
1097+
* @example
1098+
* ```typescript
1099+
* const metadata = await client.getSessionMetadata("session-123");
1100+
* if (metadata) {
1101+
* console.log(`Session started at: ${metadata.startTime}`);
1102+
* }
1103+
* ```
1104+
*/
1105+
async getSessionMetadata(sessionId: string): Promise<SessionMetadata | undefined> {
1106+
if (!this.connection) {
1107+
throw new Error("Client not connected");
1108+
}
1109+
1110+
const response = await this.connection.sendRequest("session.getMetadata", { sessionId });
1111+
const { session } = response as {
1112+
session?: {
1113+
sessionId: string;
1114+
startTime: string;
1115+
modifiedTime: string;
1116+
summary?: string;
1117+
isRemote: boolean;
1118+
context?: SessionContext;
1119+
};
1120+
};
1121+
1122+
if (!session) {
1123+
return undefined;
1124+
}
1125+
1126+
return CopilotClient.toSessionMetadata(session);
1127+
}
1128+
1129+
private static toSessionMetadata(raw: {
1130+
sessionId: string;
1131+
startTime: string;
1132+
modifiedTime: string;
1133+
summary?: string;
1134+
isRemote: boolean;
1135+
context?: SessionContext;
1136+
}): SessionMetadata {
1137+
return {
1138+
sessionId: raw.sessionId,
1139+
startTime: new Date(raw.startTime),
1140+
modifiedTime: new Date(raw.modifiedTime),
1141+
summary: raw.summary,
1142+
isRemote: raw.isRemote,
1143+
context: raw.context,
1144+
};
10921145
}
10931146

10941147
/**

nodejs/test/e2e/session.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,28 @@ describe("Sessions", async () => {
4949
}
5050
});
5151

52+
it("should get session metadata by ID", { timeout: 60000 }, async () => {
53+
const session = await client.createSession({ onPermissionRequest: approveAll });
54+
expect(session.sessionId).toMatch(/^[a-f0-9-]+$/);
55+
56+
// Send a message to persist the session to disk
57+
await session.sendAndWait({ prompt: "Say hello" });
58+
await new Promise((r) => setTimeout(r, 200));
59+
60+
// Get metadata for the session we just created
61+
const metadata = await client.getSessionMetadata(session.sessionId);
62+
63+
expect(metadata).toBeDefined();
64+
expect(metadata!.sessionId).toBe(session.sessionId);
65+
expect(metadata!.startTime).toBeInstanceOf(Date);
66+
expect(metadata!.modifiedTime).toBeInstanceOf(Date);
67+
expect(typeof metadata!.isRemote).toBe("boolean");
68+
69+
// Verify non-existent session returns undefined
70+
const notFound = await client.getSessionMetadata("non-existent-session-id");
71+
expect(notFound).toBeUndefined();
72+
});
73+
5274
it("should have stateful conversation", async () => {
5375
const session = await client.createSession({ onPermissionRequest: approveAll });
5476
const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" });

python/copilot/client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1679,6 +1679,36 @@ async def list_sessions(self, filter: SessionListFilter | None = None) -> list[S
16791679
sessions_data = response.get("sessions", [])
16801680
return [SessionMetadata.from_dict(session) for session in sessions_data]
16811681

1682+
async def get_session_metadata(self, session_id: str) -> SessionMetadata | None:
1683+
"""
1684+
Get metadata for a specific session by ID.
1685+
1686+
This provides an efficient O(1) lookup of a single session's metadata
1687+
instead of listing all sessions. Returns None if the session is not found.
1688+
1689+
Args:
1690+
session_id: The ID of the session to look up.
1691+
1692+
Returns:
1693+
A SessionMetadata object, or None if the session was not found.
1694+
1695+
Raises:
1696+
RuntimeError: If the client is not connected.
1697+
1698+
Example:
1699+
>>> metadata = await client.get_session_metadata("session-123")
1700+
>>> if metadata:
1701+
... print(f"Session started at: {metadata.startTime}")
1702+
"""
1703+
if not self._client:
1704+
raise RuntimeError("Client not connected")
1705+
1706+
response = await self._client.request("session.getMetadata", {"sessionId": session_id})
1707+
session_data = response.get("session")
1708+
if session_data is None:
1709+
return None
1710+
return SessionMetadata.from_dict(session_data)
1711+
16821712
async def delete_session(self, session_id: str) -> None:
16831713
"""
16841714
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
@@ -320,6 +320,35 @@ async def test_should_delete_session(self, ctx: E2ETestContext):
320320
session_id, on_permission_request=PermissionHandler.approve_all
321321
)
322322

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

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)