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
8 changes: 4 additions & 4 deletions .github/agents/docs-maintenance.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ cat nodejs/src/types.ts | grep -A 10 "export interface ExportSessionOptions"
**Must match:**
- `CopilotClient` constructor options: `cliPath`, `cliUrl`, `useStdio`, `port`, `logLevel`, `autoStart`, `autoRestart`, `env`, `githubToken`, `useLoggedInUser`
- `createSession()` config: `model`, `tools`, `hooks`, `systemMessage`, `mcpServers`, `availableTools`, `excludedTools`, `streaming`, `reasoningEffort`, `provider`, `infiniteSessions`, `customAgents`, `workingDirectory`
- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `disconnect()`, `abort()`, `on()`, `once()`, `off()`
- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `shutdown()`, `disconnect()`, `abort()`, `on()`, `once()`, `off()`
- Hook names: `onPreToolUse`, `onPostToolUse`, `onUserPromptSubmitted`, `onSessionStart`, `onSessionEnd`, `onErrorOccurred`

#### Python Validation
Expand All @@ -362,7 +362,7 @@ cat python/copilot/types.py | grep -A 15 "class SessionHooks"
**Must match (snake_case):**
- `CopilotClient` options: `cli_path`, `cli_url`, `use_stdio`, `port`, `log_level`, `auto_start`, `auto_restart`, `env`, `github_token`, `use_logged_in_user`
- `create_session()` config keys: `model`, `tools`, `hooks`, `system_message`, `mcp_servers`, `available_tools`, `excluded_tools`, `streaming`, `reasoning_effort`, `provider`, `infinite_sessions`, `custom_agents`, `working_directory`
- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `disconnect()`, `abort()`, `export_session()`
- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `shutdown()`, `disconnect()`, `abort()`, `export_session()`
- Hook names: `on_pre_tool_use`, `on_post_tool_use`, `on_user_prompt_submitted`, `on_session_start`, `on_session_end`, `on_error_occurred`

#### Go Validation
Expand All @@ -380,7 +380,7 @@ cat go/types.go | grep -A 15 "type SessionHooks struct"
**Must match (PascalCase for exported):**
- `ClientOptions` fields: `CLIPath`, `CLIUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Env`, `GithubToken`, `UseLoggedInUser`
- `SessionConfig` fields: `Model`, `Tools`, `Hooks`, `SystemMessage`, `MCPServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory`
- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Disconnect()`, `Abort()`, `ExportSession()`
- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Shutdown()`, `Disconnect()`, `Abort()`, `ExportSession()`
- Hook fields: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred`

#### .NET Validation
Expand All @@ -398,7 +398,7 @@ cat dotnet/src/Types.cs | grep -A 15 "public class SessionHooks"
**Must match (PascalCase):**
- `CopilotClientOptions` properties: `CliPath`, `CliUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Environment`, `GithubToken`, `UseLoggedInUser`
- `SessionConfig` properties: `Model`, `Tools`, `Hooks`, `SystemMessage`, `McpServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory`
- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()`
- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `ShutdownAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()`
- Hook properties: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred`

#### Common Sample Errors to Check
Expand Down
1 change: 1 addition & 0 deletions docs/troubleshooting/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b
| Create session | `createSession()` | Full config support |
| Resume session | `resumeSession()` | With infinite session workspaces |
| Disconnect session | `disconnect()` | Release in-memory resources |
| Shutdown session | `shutdown()` | End session server-side, keeping handlers active |
| Destroy session *(deprecated)* | `destroy()` | Use `disconnect()` instead |
| Delete session | `deleteSession()` | Remove from storage |
| List sessions | `listSessions()` | All stored sessions |
Expand Down
6 changes: 5 additions & 1 deletion dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ Get all events/messages from this session.

##### `DisposeAsync(): ValueTask`

Close the session and release in-memory resources. Session data on disk is preserved — the conversation can be resumed later via `ResumeSessionAsync()`. To permanently delete session data, use `client.DeleteSessionAsync()`.
Close the session and release in-memory resources. Calls `ShutdownAsync()` first if not already called. Session data on disk is preserved — the conversation can be resumed later via `ResumeSessionAsync()`. To permanently delete session data, use `client.DeleteSessionAsync()`.

##### `ShutdownAsync(CancellationToken): Task`

Shut down the session on the server without clearing local event handlers. Call this before `DisposeAsync()` when you want to observe the `SessionShutdownEvent`.

```csharp
// Preferred: automatic cleanup via await using
Expand Down
45 changes: 43 additions & 2 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public sealed partial class CopilotSession : IAsyncDisposable
private readonly SemaphoreSlim _hooksLock = new(1, 1);
private SessionRpc? _sessionRpc;
private int _isDisposed;
private int _isShutdown;

/// <summary>
/// Gets the unique identifier for this session.
Expand Down Expand Up @@ -696,6 +697,42 @@ public async Task LogAsync(string message, SessionLogRequestLevel? level = null,
await Rpc.LogAsync(message, level, ephemeral, cancellationToken);
}

/// <summary>
/// Shuts down this session on the server without clearing local event handlers.
/// </summary>
/// <remarks>
/// <para>
/// Call this before <see cref="DisposeAsync"/> when you want to observe the
/// <see cref="SessionShutdownEvent"/>. The event is dispatched to registered handlers
/// after this method returns. Once you have processed the event, call
/// <see cref="DisposeAsync"/> to clear handlers and release local resources.
/// </para>
/// <para>
/// If the session has already been shut down, this is a no-op.
/// </para>
/// </remarks>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>A task representing the asynchronous shutdown operation.</returns>
/// <example>
/// <code>
/// var shutdownTcs = new TaskCompletionSource();
/// session.On(evt => { if (evt is SessionShutdownEvent) shutdownTcs.TrySetResult(); });
/// await session.ShutdownAsync();
/// await shutdownTcs.Task;
/// await session.DisposeAsync();
/// </code>
/// </example>
public async Task ShutdownAsync(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _isShutdown, 1) == 1)
{
return;
}

await InvokeRpcAsync<object>(
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], cancellationToken);
}

/// <summary>
/// Closes this session and releases all in-memory resources (event handlers,
/// tool handlers, permission handlers).
Expand All @@ -710,6 +747,11 @@ public async Task LogAsync(string message, SessionLogRequestLevel? level = null,
/// <see cref="CopilotClient.DeleteSessionAsync"/> instead.
/// </para>
/// <para>
/// If <see cref="ShutdownAsync"/> was not called first, this method calls it automatically.
/// In that case the <see cref="SessionShutdownEvent"/> may not be observed because handlers
/// are cleared immediately after the server responds.
/// </para>
/// <para>
/// After calling this method, the session object can no longer be used.
/// </para>
/// </remarks>
Expand All @@ -733,8 +775,7 @@ public async ValueTask DisposeAsync()

try
{
await InvokeRpcAsync<object>(
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None);
await ShutdownAsync();
}
catch (ObjectDisposedException)
{
Expand Down
9 changes: 9 additions & 0 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ public async Task Should_Receive_Session_Events()
var session = await CreateSessionAsync();
var receivedEvents = new List<SessionEvent>();
var idleReceived = new TaskCompletionSource<bool>();
var shutdownReceived = new TaskCompletionSource<bool>();

session.On(evt =>
{
Expand All @@ -256,6 +257,10 @@ public async Task Should_Receive_Session_Events()
{
idleReceived.TrySetResult(true);
}
else if (evt is SessionShutdownEvent)
{
shutdownReceived.TrySetResult(true);
}
});

// Send a message to trigger events
Expand All @@ -276,6 +281,10 @@ public async Task Should_Receive_Session_Events()
Assert.NotNull(assistantMessage);
Assert.Contains("300", assistantMessage!.Data.Content);

// Shut down session and verify shutdown event is received
await session.ShutdownAsync();
await shutdownReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains(receivedEvents, evt => evt is SessionShutdownEvent);
await session.DisposeAsync();
}

Expand Down
1 change: 1 addition & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
- `Abort(ctx context.Context) error` - Abort the currently processing message
- `GetMessages(ctx context.Context) ([]SessionEvent, error)` - Get message history
- `Disconnect() error` - Disconnect the session (releases in-memory resources, preserves disk state)
- `Shutdown() error` - Shut down the session on the server without clearing local handlers (call before `Disconnect()` to observe the `session.shutdown` event)
- `Destroy() error` - *(Deprecated)* Use `Disconnect()` instead

### Helper Functions
Expand Down
32 changes: 30 additions & 2 deletions go/internal/e2e/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,13 +594,19 @@
}

var receivedEvents []copilot.SessionEvent
idle := make(chan bool)
idle := make(chan struct{}, 1)
shutdown := make(chan struct{}, 1)

session.On(func(event copilot.SessionEvent) {
receivedEvents = append(receivedEvents, event)
if event.Type == "session.idle" {

Check failure on line 602 in go/internal/e2e/session_test.go

View workflow job for this annotation

GitHub Actions / Go SDK Tests (ubuntu-latest)

QF1003: could use tagged switch on event.Type (staticcheck)
select {
case idle <- true:
case idle <- struct{}{}:
default:
}
} else if event.Type == "session.shutdown" {
select {
case shutdown <- struct{}{}:
default:
}
}
Expand Down Expand Up @@ -656,6 +662,28 @@
if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "300") {
t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content)
}

// Shut down session and verify shutdown event is received
if err := session.Shutdown(); err != nil {
t.Fatalf("Failed to shut down session: %v", err)
}
select {
case <-shutdown:
case <-time.After(5 * time.Second):
t.Fatal("Timed out waiting for session.shutdown")
}
hasShutdown := false
for _, evt := range receivedEvents {
if evt.Type == "session.shutdown" {
hasShutdown = true
}
}
if !hasShutdown {
t.Error("Expected to receive session.shutdown event")
}
if err := session.Destroy(); err != nil {

Check failure on line 684 in go/internal/e2e/session_test.go

View workflow job for this annotation

GitHub Actions / Go SDK Tests (ubuntu-latest)

SA1019: session.Destroy is deprecated: Use [Session.Disconnect] instead. Destroy will be removed in a future release. (staticcheck)
t.Fatalf("Failed to destroy session: %v", err)
}
})

t.Run("should create session with custom config dir", func(t *testing.T) {
Expand Down
45 changes: 42 additions & 3 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"

"github.com/github/copilot-sdk/go/internal/jsonrpc2"
Expand Down Expand Up @@ -64,6 +65,7 @@ type Session struct {
userInputMux sync.RWMutex
hooks *SessionHooks
hooksMux sync.RWMutex
isShutdown atomic.Bool

// RPC provides typed session-scoped RPC methods.
RPC *rpc.SessionRpc
Expand Down Expand Up @@ -607,6 +609,40 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
return response.Events, nil
}

// Shutdown ends this session on the server without clearing local event handlers.
//
// Call this before [Session.Disconnect] when you want to observe the session.shutdown
// event. The event is dispatched to registered handlers after this method returns.
// Once you have processed the event, call [Session.Disconnect] to clear handlers and
// release local resources.
//
// If the session has already been shut down, this is a no-op.
//
// Returns an error if the connection fails.
//
// Example:
//
// session.On(func(event copilot.SessionEvent) {
// if event.Type == copilot.SessionShutdown {
// fmt.Println("Shutdown metrics:", event.Data)
// }
// })
// if err := session.Shutdown(); err != nil {
// log.Printf("Failed to shut down session: %v", err)
// }
// // ... wait for the shutdown event ...
// session.Disconnect()
func (s *Session) Shutdown() error {
if s.isShutdown.Swap(true) {
return nil
}
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
if err != nil {
return fmt.Errorf("failed to shut down session: %w", err)
}
return nil
}

// Disconnect closes this session and releases all in-memory resources (event
// handlers, tool handlers, permission handlers).
//
Expand All @@ -617,6 +653,10 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
//
// After calling this method, the session object can no longer be used.
//
// If [Session.Shutdown] was not called first, this method calls it automatically.
// In that case the session.shutdown event may not be observed because handlers
// are cleared immediately after the server responds.
//
// Returns an error if the connection fails.
//
// Example:
Expand All @@ -626,9 +666,8 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
// log.Printf("Failed to disconnect session: %v", err)
// }
func (s *Session) Disconnect() error {
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
if err != nil {
return fmt.Errorf("failed to disconnect session: %w", err)
if err := s.Shutdown(); err != nil {
return err
}

// Clear handlers
Expand Down
Loading
Loading