diff --git a/cookbook/go.sum b/cookbook/go.sum new file mode 100644 index 00000000..213d0035 --- /dev/null +++ b/cookbook/go.sum @@ -0,0 +1,6 @@ +github.com/github/copilot-sdk/go v0.1.18 h1:S1ocOfTKxiNGtj+/qp4z+RZeOr9hniqy3UqIIYZxsuQ= +github.com/github/copilot-sdk/go v0.1.18/go.mod h1:0SYT+64k347IDT0Trn4JHVFlUhPtGSE6ab479tU/+tY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/go/README.md b/go/README.md index 1352d9a3..ac6a5397 100644 --- a/go/README.md +++ b/go/README.md @@ -80,6 +80,8 @@ func main() { - `CreateSession(config *SessionConfig) (*Session, error)` - Create a new session - `ResumeSession(sessionID string) (*Session, error)` - Resume an existing session - `ResumeSessionWithOptions(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume with additional configuration +- `ListSessions() ([]SessionMetadata, error)` - List all sessions known to the server +- `DeleteSession(sessionID string) error` - Delete a session permanently - `GetState() ConnectionState` - Get connection state - `Ping(message string) (*PingResponse, error)` - Ping the server diff --git a/go/client.go b/go/client.go index ada263de..95ca7398 100644 --- a/go/client.go +++ b/go/client.go @@ -727,6 +727,107 @@ func (c *Client) ResumeSessionWithOptions(sessionID string, config *ResumeSessio return session, nil } +// ListSessions returns metadata about all sessions known to the server. +// +// Returns a list of SessionMetadata for all available sessions, including their IDs, +// timestamps, and optional summaries. +// +// Example: +// +// sessions, err := client.ListSessions() +// if err != nil { +// log.Fatal(err) +// } +// for _, session := range sessions { +// fmt.Printf("Session: %s\n", session.SessionID) +// } +func (c *Client) ListSessions() ([]SessionMetadata, error) { + if c.client == nil { + if c.autoStart { + if err := c.Start(); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("client not connected. Call Start() first") + } + } + + result, err := c.client.Request("session.list", map[string]interface{}{}) + if err != nil { + return nil, err + } + + // Marshal and unmarshal to convert map to struct + jsonBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal sessions response: %w", err) + } + + var response ListSessionsResponse + if err := json.Unmarshal(jsonBytes, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal sessions response: %w", err) + } + + return response.Sessions, nil +} + +// DeleteSession permanently deletes a session and all its conversation history. +// +// The session cannot be resumed after deletion. If the session is in the local +// sessions map, it will be removed. +// +// Example: +// +// if err := client.DeleteSession("session-123"); err != nil { +// log.Fatal(err) +// } +func (c *Client) DeleteSession(sessionID string) error { + if c.client == nil { + if c.autoStart { + if err := c.Start(); err != nil { + return err + } + } else { + return fmt.Errorf("client not connected. Call Start() first") + } + } + + params := map[string]interface{}{ + "sessionId": sessionID, + } + + result, err := c.client.Request("session.delete", params) + if err != nil { + return err + } + + // Marshal and unmarshal to convert map to struct + jsonBytes, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("failed to marshal delete response: %w", err) + } + + var response DeleteSessionResponse + if err := json.Unmarshal(jsonBytes, &response); err != nil { + return fmt.Errorf("failed to unmarshal delete response: %w", err) + } + + if !response.Success { + errorMsg := "unknown error" + if response.Error != nil { + errorMsg = *response.Error + } + return fmt.Errorf("failed to delete session %s: %s", sessionID, errorMsg) + } + + // Remove from local sessions map if present + c.sessionsMux.Lock() + delete(c.sessions, sessionID) + c.sessionsMux.Unlock() + + return nil +} + // GetState returns the current connection state of the client. // // Possible states: StateDisconnected, StateConnecting, StateConnected, StateError. diff --git a/go/e2e/session_test.go b/go/e2e/session_test.go index f1677d44..6368fa18 100644 --- a/go/e2e/session_test.go +++ b/go/e2e/session_test.go @@ -750,6 +750,135 @@ func TestSession(t *testing.T) { t.Errorf("Expected assistant message to contain '2', got %v", assistantMessage.Data.Content) } }) + + t.Run("should list sessions", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Create a couple of sessions and send messages to persist them + session1, err := client.CreateSession(nil) + if err != nil { + t.Fatalf("Failed to create session1: %v", err) + } + + _, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "Say hello"}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message to session1: %v", err) + } + + session2, err := client.CreateSession(nil) + if err != nil { + t.Fatalf("Failed to create session2: %v", err) + } + + _, err = session2.SendAndWait(copilot.MessageOptions{Prompt: "Say goodbye"}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message to session2: %v", err) + } + + // Small delay to ensure session files are written to disk + time.Sleep(200 * time.Millisecond) + + // List sessions and verify they're included + sessions, err := client.ListSessions() + if err != nil { + t.Fatalf("Failed to list sessions: %v", err) + } + + // Verify it's a list + if sessions == nil { + t.Fatal("Expected sessions to be non-nil") + } + + // Extract session IDs + sessionIDs := make([]string, len(sessions)) + for i, s := range sessions { + sessionIDs[i] = s.SessionID + } + + // Verify both sessions are in the list + if !contains(sessionIDs, session1.SessionID) { + t.Errorf("Expected session1 ID %s to be in sessions list", session1.SessionID) + } + if !contains(sessionIDs, session2.SessionID) { + t.Errorf("Expected session2 ID %s to be in sessions list", session2.SessionID) + } + + // Verify session metadata structure + for _, sessionData := range sessions { + if sessionData.SessionID == "" { + t.Error("Expected sessionId to be non-empty") + } + if sessionData.StartTime == "" { + t.Error("Expected startTime to be non-empty") + } + if sessionData.ModifiedTime == "" { + t.Error("Expected modifiedTime to be non-empty") + } + // isRemote is a boolean, so it's always set + } + }) + + t.Run("should delete session", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Create a session and send a message to persist it + session, err := client.CreateSession(nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(copilot.MessageOptions{Prompt: "Hello"}, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + sessionID := session.SessionID + + // Small delay to ensure session file is written to disk + time.Sleep(200 * time.Millisecond) + + // Verify session exists in the list + sessions, err := client.ListSessions() + if err != nil { + t.Fatalf("Failed to list sessions: %v", err) + } + + sessionIDs := make([]string, len(sessions)) + for i, s := range sessions { + sessionIDs[i] = s.SessionID + } + + if !contains(sessionIDs, sessionID) { + t.Errorf("Expected session ID %s to be in sessions list before delete", sessionID) + } + + // Delete the session + err = client.DeleteSession(sessionID) + if err != nil { + t.Fatalf("Failed to delete session: %v", err) + } + + // Verify session no longer exists in the list + sessionsAfter, err := client.ListSessions() + if err != nil { + t.Fatalf("Failed to list sessions after delete: %v", err) + } + + sessionIDsAfter := make([]string, len(sessionsAfter)) + for i, s := range sessionsAfter { + sessionIDsAfter[i] = s.SessionID + } + + if contains(sessionIDsAfter, sessionID) { + t.Errorf("Expected session ID %s to NOT be in sessions list after delete", sessionID) + } + + // Verify we cannot resume the deleted session + _, err = client.ResumeSession(sessionID) + if err == nil { + t.Error("Expected error when resuming deleted session") + } + }) } func getSystemMessage(exchange testharness.ParsedHttpExchange) string { diff --git a/go/types.go b/go/types.go index 0bc8b6f7..7a420cd6 100644 --- a/go/types.go +++ b/go/types.go @@ -375,3 +375,28 @@ type ModelInfo struct { type GetModelsResponse struct { Models []ModelInfo `json:"models"` } + +// SessionMetadata contains metadata about a session +type SessionMetadata struct { + SessionID string `json:"sessionId"` + StartTime string `json:"startTime"` + ModifiedTime string `json:"modifiedTime"` + Summary *string `json:"summary,omitempty"` + IsRemote bool `json:"isRemote"` +} + +// ListSessionsResponse is the response from session.list +type ListSessionsResponse struct { + Sessions []SessionMetadata `json:"sessions"` +} + +// DeleteSessionRequest is the request for session.delete +type DeleteSessionRequest struct { + SessionID string `json:"sessionId"` +} + +// DeleteSessionResponse is the response from session.delete +type DeleteSessionResponse struct { + Success bool `json:"success"` + Error *string `json:"error,omitempty"` +} diff --git a/test/package-lock.json b/test/package-lock.json new file mode 100644 index 00000000..fed62a41 --- /dev/null +++ b/test/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "test", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}