diff --git a/go/README.md b/go/README.md index b9506151..86ed497e 100644 --- a/go/README.md +++ b/go/README.md @@ -104,6 +104,7 @@ That's it! When your application calls `copilot.NewClient` without a `CLIPath` n - `ResumeSessionWithOptions(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume with additional configuration - `ListSessions(filter *SessionListFilter) ([]SessionMetadata, error)` - List sessions (with optional filter) - `DeleteSession(sessionID string) error` - Delete a session permanently +- `GetLastSessionID(ctx context.Context) (*string, error)` - Get the ID of the most recently updated session - `GetState() ConnectionState` - Get connection state - `Ping(message string) (*PingResponse, error)` - Ping the server - `GetForegroundSessionID(ctx context.Context) (*string, error)` - Get the session ID currently displayed in TUI (TUI+server mode only) diff --git a/go/client.go b/go/client.go index c88a68ac..a6486586 100644 --- a/go/client.go +++ b/go/client.go @@ -726,6 +726,40 @@ func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { return nil } +// GetLastSessionID returns the ID of the most recently updated session. +// +// This is useful for resuming the last conversation when the session ID +// was not stored. Returns nil if no sessions exist. +// +// Example: +// +// lastID, err := client.GetLastSessionID(context.Background()) +// if err != nil { +// log.Fatal(err) +// } +// if lastID != nil { +// session, err := client.ResumeSession(context.Background(), *lastID, &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) +// } +func (c *Client) GetLastSessionID(ctx context.Context) (*string, error) { + if err := c.ensureConnected(); err != nil { + return nil, err + } + + result, err := c.client.Request("session.getLastId", getLastSessionIDRequest{}) + if err != nil { + return nil, err + } + + var response getLastSessionIDResponse + if err := json.Unmarshal(result, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal getLastId response: %w", err) + } + + return response.SessionID, nil +} + // GetForegroundSessionID returns the ID of the session currently displayed in the TUI. // // This is only available when connecting to a server running in TUI+server mode diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 0c50ba8d..cd86905d 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -828,6 +828,40 @@ func TestSession(t *testing.T) { t.Error("Expected error when resuming deleted session") } }) + t.Run("should get last session id", 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 data is flushed to disk + time.Sleep(500 * time.Millisecond) + + lastSessionID, err := client.GetLastSessionID(t.Context()) + if err != nil { + t.Fatalf("Failed to get last session ID: %v", err) + } + + if lastSessionID == nil { + t.Fatal("Expected last session ID to be non-nil") + } + + if *lastSessionID != session.SessionID { + t.Errorf("Expected last session ID to be %s, got %s", session.SessionID, *lastSessionID) + } + + if err := session.Destroy(); err != nil { + t.Fatalf("Failed to destroy session: %v", err) + } + }) } func getSystemMessage(exchange testharness.ParsedHttpExchange) string { diff --git a/go/types.go b/go/types.go index 8f034db7..30bbde2e 100644 --- a/go/types.go +++ b/go/types.go @@ -749,6 +749,14 @@ type deleteSessionResponse struct { Error *string `json:"error,omitempty"` } +// getLastSessionIDRequest is the request for session.getLastId +type getLastSessionIDRequest struct{} + +// getLastSessionIDResponse is the response from session.getLastId +type getLastSessionIDResponse struct { + SessionID *string `json:"sessionId,omitempty"` +} + // getForegroundSessionRequest is the request for session.getForeground type getForegroundSessionRequest struct{} diff --git a/python/copilot/client.py b/python/copilot/client.py index 59bc9754..bb6df21f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -961,6 +961,30 @@ async def delete_session(self, session_id: str) -> None: if session_id in self._sessions: del self._sessions[session_id] + async def get_last_session_id(self) -> str | None: + """ + Get the ID of the most recently updated session. + + This is useful for resuming the last conversation when the session ID + was not stored. + + Returns: + The session ID, or None if no sessions exist. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> last_id = await client.get_last_session_id() + >>> if last_id: + ... session = await client.resume_session(last_id, {"on_permission_request": PermissionHandler.approve_all}) + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request("session.getLastId", {}) + return response.get("sessionId") + async def get_foreground_session_id(self) -> str | None: """ Get the ID of the session currently displayed in the TUI. diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index e6e4b303..e268a0bd 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -303,6 +303,23 @@ async def test_should_delete_session(self, ctx: E2ETestContext): session_id, {"on_permission_request": PermissionHandler.approve_all} ) + async def test_should_get_last_session_id(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({"prompt": "Say hello"}) + + # Small delay to ensure session data is flushed to disk + await asyncio.sleep(0.5) + + last_session_id = await ctx.client.get_last_session_id() + assert last_session_id == session.session_id + + await session.destroy() + async def test_should_create_session_with_custom_tool(self, ctx: E2ETestContext): # This test uses the low-level Tool() API to show that Pydantic is optional def get_secret_number_handler(invocation): diff --git a/test/snapshots/session/should_get_last_session_id.yaml b/test/snapshots/session/should_get_last_session_id.yaml new file mode 100644 index 00000000..3b9da534 --- /dev/null +++ b/test/snapshots/session/should_get_last_session_id.yaml @@ -0,0 +1,10 @@ +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 with your software engineering tasks.