Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,36 @@ public async Task<List<SessionMetadata>> ListSessionsAsync(SessionListFilter? fi
return response.Sessions;
}

/// <summary>
/// Gets metadata for a specific session by ID.
/// </summary>
/// <remarks>
/// This provides an efficient O(1) lookup of a single session's metadata
/// instead of listing all sessions.
/// </remarks>
/// <param name="sessionId">The ID of the session to look up.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
/// <returns>A task that resolves with the <see cref="SessionMetadata"/>, or null if the session was not found.</returns>
/// <exception cref="InvalidOperationException">Thrown when the client is not connected.</exception>
/// <example>
/// <code>
/// var metadata = await client.GetSessionMetadataAsync("session-123");
/// if (metadata != null)
/// {
/// Console.WriteLine($"Session started at: {metadata.StartTime}");
/// }
/// </code>
/// </example>
public async Task<SessionMetadata?> GetSessionMetadataAsync(string sessionId, CancellationToken cancellationToken = default)
{
var connection = await EnsureConnectedAsync(cancellationToken);

var response = await InvokeRpcAsync<GetSessionMetadataResponse>(
connection.Rpc, "session.getMetadata", [new GetSessionMetadataRequest(sessionId)], cancellationToken);

return response.Session;
}

/// <summary>
/// Gets the ID of the session currently displayed in the TUI.
/// </summary>
Expand Down Expand Up @@ -1629,6 +1659,12 @@ internal record ListSessionsRequest(
internal record ListSessionsResponse(
List<SessionMetadata> Sessions);

internal record GetSessionMetadataRequest(
string SessionId);

internal record GetSessionMetadataResponse(
SessionMetadata? Session);

internal record UserInputRequestResponse(
string Answer,
bool WasFreeform);
Expand Down Expand Up @@ -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))]
Expand Down
17 changes: 17 additions & 0 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
32 changes: 32 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
55 changes: 55 additions & 0 deletions go/internal/e2e/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
49 changes: 49 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionMetadata | undefined> {
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.
*
Expand Down
19 changes: 19 additions & 0 deletions nodejs/test/e2e/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?" });
Expand Down
34 changes: 34 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions python/e2e/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions test/snapshots/session/should_get_session_metadata.yaml
Original file line number Diff line number Diff line change
@@ -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?
Loading