diff --git a/docs/troubleshooting/compatibility.md b/docs/troubleshooting/compatibility.md index c68d59cc7..e48e92200 100644 --- a/docs/troubleshooting/compatibility.md +++ b/docs/troubleshooting/compatibility.md @@ -15,6 +15,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | **Session Management** | | | | Create session | `createSession()` | Full config support | | Resume session | `resumeSession()` | With infinite session workspaces | +| Reset session | `session.reset(config)` | Abandon current runtime session and return a fresh one from explicit config; host apps clear their own UI | | Disconnect session | `disconnect()` | Release in-memory resources | | Destroy session *(deprecated)* | `destroy()` | Use `disconnect()` instead | | Delete session | `deleteSession()` | Remove from storage | @@ -99,7 +100,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Export to file | `--share`, `/share` | Not in protocol | | Export to gist | `--share-gist`, `/share gist` | Not in protocol | | **Interactive UI** | | | -| Slash commands | `/help`, `/clear`, `/exit`, etc. | TUI-only | +| Slash commands | `/help`, `/exit`, etc. | TUI-only; use `session.reset(config)` for the lifecycle portion of `/clear` / `/reset` behavior | | Agent picker dialog | `/agent` | Interactive UI | | Diff mode dialog | `/diff` | Interactive UI | | Feedback dialog | `/feedback` | Interactive UI | @@ -137,7 +138,6 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Logout | `/logout`, `copilot auth logout` | Direct CLI | | User info | `/user` | TUI command | | **Session Operations** | | | -| Clear conversation | `/clear` | TUI-only | | Plan view | `/plan` | TUI-only (use SDK `session.rpc.plan.*` instead) | | Session management | `/session`, `/resume`, `/rename` | TUI workflow | | Fleet mode (interactive) | `/fleet` | TUI-only (use SDK `session.rpc.fleet.start()` instead) | diff --git a/dotnet/README.md b/dotnet/README.md index 9b266421f..34b37383b 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -533,6 +533,22 @@ When the user types `/deploy staging` in the CLI, the SDK receives a `command.ex Commands are sent to the CLI on both `CreateSessionAsync` and `ResumeSessionAsync`, so you can update the command set when resuming. +## Resetting a Session + +Use `session.ResetAsync(config)` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```csharp +var result = await session.ResetAsync(new SessionConfig +{ + Model = "gpt-5", + OnPermissionRequest = PermissionHandler.ApproveAll, +}); +session = result.Session; +// Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `PreviousSessionId` identifies the abandoned session. The old `CopilotSession` object is closed after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ## UI Elicitation When the session has elicitation support — either from the CLI's TUI or from another client that registered an `OnElicitationRequest` handler (see [Elicitation Requests](#elicitation-requests)) — the SDK can request interactive form dialogs from the user. The `session.Ui` object provides convenience methods built on a single generic elicitation RPC. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4e8715bd5..f9c376f4e 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -67,6 +67,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable /// that has not been explicitly disposed or removed. /// internal readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _resettingSessions = new(); private readonly CopilotClientOptions _options; private readonly RuntimeConnection _connection; @@ -345,6 +346,7 @@ public async Task StopAsync() } _sessions.Clear(); + _resettingSessions.Clear(); await CleanupConnectionAsync(errors); @@ -376,6 +378,7 @@ public async Task StopAsync() public async Task ForceStopAsync() { _sessions.Clear(); + _resettingSessions.Clear(); var errors = new List(); await CleanupConnectionAsync(errors); @@ -1153,6 +1156,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) { session.RemoveFromClient(); @@ -1173,6 +1177,41 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes return session; } + internal async Task ResetSessionAsync( + CopilotSession session, + SessionConfig config, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(config); + + var previousSessionId = session.SessionId; + if (!_sessions.TryGetValue(previousSessionId, out var trackedSession) || !ReferenceEquals(trackedSession, session)) + { + throw new InvalidOperationException($"Cannot reset session {previousSessionId}: it is not active on this client."); + } + + if (!_resettingSessions.TryAdd(previousSessionId, 0)) + { + throw new InvalidOperationException($"Cannot reset session {previousSessionId}: reset is already in progress."); + } + + try + { + await session.Rpc.Queue.ClearAsync(cancellationToken).ConfigureAwait(false); + await session.DestroyForResetAsync(cancellationToken).ConfigureAwait(false); + + var resetConfig = config.Clone(); + resetConfig.SessionId = null; + var freshSession = await CreateSessionAsync(resetConfig, cancellationToken).ConfigureAwait(false); + return new ResetSessionResult(previousSessionId, freshSession); + } + finally + { + _resettingSessions.TryRemove(previousSessionId, out _); + } + } + /// /// Validates the health of the connection by sending a ping request. /// @@ -1331,6 +1370,7 @@ public async Task DeleteSessionAsync(string sessionId, CancellationToken cancell } RemoveSession(sessionId); + _resettingSessions.TryRemove(sessionId, out _); } /// @@ -2074,6 +2114,11 @@ private void RemoveSession(string sessionId) _sessions.TryRemove(sessionId, out _); } + internal void RemoveSession(CopilotSession session) + { + ((ICollection>)_sessions).Remove(new(session.SessionId, session)); + } + /// /// Disposes the synchronously. /// @@ -2245,7 +2290,10 @@ private async Task PumpAsync(Process process, ILogger logger, CancellationToken Buffer.AppendLine(line); } - logger.LogWarning("[CLI] {Line}", line); + if (logger.IsEnabled(LogLevel.Warning)) + { + logger.LogWarning("[CLI] {Line}", line); + } } } catch (Exception e) when (cancellationToken.IsCancellationRequested diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 912d5a529..eeca2bc8e 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -470,7 +470,10 @@ private void HandleResponse(JsonElement message, JsonElement idProp) } catch (Exception ex) { - _logger.LogWarning(ex, "Inline response callback for request {RequestId} threw", id); + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning(ex, "Inline response callback for request {RequestId} threw", id); + } pending.TrySetException(ex); return; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 095c1abf7..582bb7b69 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -194,7 +194,7 @@ internal CopilotSession( /// internal void RemoveFromClient() { - ((ICollection>)_parentClient._sessions).Remove(new(SessionId, this)); + _parentClient.RemoveSession(this); } internal void StartProcessingEvents() @@ -1729,6 +1729,35 @@ await InvokeRpcAsync( } _eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray.Empty); + ClearLocalState(); + } + + internal async Task DestroyForResetAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _isDisposed, 1) == 1) + { + return; + } + + try + { + await InvokeRpcAsync( + "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], cancellationToken); + } + catch + { + Interlocked.Exchange(ref _isDisposed, 0); + throw; + } + + _eventChannel.Writer.TryComplete(); + RemoveFromClient(); + _eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray.Empty); + ClearLocalState(); + } + + private void ClearLocalState() + { _toolHandlers.Clear(); _commandHandlers.Clear(); @@ -1739,6 +1768,25 @@ await InvokeRpcAsync( _autoModeSwitchHandler = null; } + /// + /// Resets this conversation by closing the underlying runtime session and + /// creating a fresh session from . + /// + /// + /// Use the returned session for subsequent work. The SDK does not clear + /// host-owned UI state, local drafts, or app persistence. If reset fails + /// after teardown starts, treat the old session as no longer usable and + /// create or resume another session explicitly. + /// + /// Configuration for the replacement session. Any is ignored. + /// A token to cancel the reset operation. + /// The fresh session and the previous session ID. + public Task ResetAsync(SessionConfig config, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + return _parentClient.ResetSessionAsync(this, config, cancellationToken); + } + [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in broadcast event handler")] private partial void LogBroadcastHandlerError(Exception exception); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7a2ad2951..c82294dd6 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2928,6 +2928,13 @@ private SessionConfig(SessionConfig? other) : base(other) public SessionConfig Clone() => new(this); } +/// +/// Result returned by . +/// +/// The session ID that was closed and replaced. +/// The fresh session created from the supplied reset configuration. +public sealed record ResetSessionResult(string PreviousSessionId, CopilotSession Session); + /// /// Configuration options for resuming an existing Copilot session. /// diff --git a/go/README.md b/go/README.md index 0ceaabb73..8a22c58f9 100644 --- a/go/README.md +++ b/go/README.md @@ -788,6 +788,24 @@ session, err := client.ResumeSession(ctx, sessionID, &copilot.ResumeSessionConfi If a handler returns an error, the SDK sends the error message back to the server. Unknown commands automatically receive an error response. +## Resetting a Session + +Use `session.Reset(ctx, config)` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```go +result, err := session.Reset(ctx, &copilot.SessionConfig{ + Model: "gpt-5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +}) +if err != nil { + return err +} +session = result.Session +// Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `PreviousSessionID` identifies the abandoned session. The old `Session` object is closed after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ## UI Elicitation The SDK provides convenience methods to ask the user questions via elicitation dialogs. These are gated by host capabilities — check `session.Capabilities().UI.Elicitation` before calling. diff --git a/go/client.go b/go/client.go index cad460557..9df1f91b0 100644 --- a/go/client.go +++ b/go/client.go @@ -88,17 +88,18 @@ func validateSessionFSConfig(config *SessionFSConfig) error { // } // defer client.Stop() type Client struct { - options ClientOptions - process *exec.Cmd - client *jsonrpc2.Client - actualPort int - actualHost string - state connectionState - sessions map[string]*Session - sessionsMux sync.Mutex - isExternalServer bool - conn net.Conn // stores net.Conn for external TCP connections - useStdio bool // resolved value from options + options ClientOptions + process *exec.Cmd + client *jsonrpc2.Client + actualPort int + actualHost string + state connectionState + sessions map[string]*Session + resettingSessions map[string]struct{} + sessionsMux sync.Mutex + isExternalServer bool + conn net.Conn // stores net.Conn for external TCP connections + useStdio bool // resolved value from options // resolved process options for the spawned runtime (zero values for URIConnection) cliPath string cliArgs []string @@ -156,12 +157,13 @@ func NewClient(options *ClientOptions) *Client { opts := ClientOptions{} client := &Client{ - options: opts, - state: stateDisconnected, - sessions: make(map[string]*Session), - actualHost: "localhost", - isExternalServer: false, - useStdio: true, + options: opts, + state: stateDisconnected, + sessions: make(map[string]*Session), + resettingSessions: make(map[string]struct{}), + actualHost: "localhost", + isExternalServer: false, + useStdio: true, } if options != nil { @@ -241,6 +243,10 @@ func NewClient(options *ClientOptions) *Client { return client } +func (c *Client) forgetSessionLocked(sessionID string) { + delete(c.sessions, sessionID) +} + // getEnvValue looks up a key in an environment slice ([]string of "KEY=VALUE"). // Returns the value if found, or empty string otherwise. func getEnvValue(env []string, key string) string { @@ -411,6 +417,7 @@ func (c *Client) Stop() error { c.sessionsMux.Lock() c.sessions = make(map[string]*Session) + c.resettingSessions = make(map[string]struct{}) c.sessionsMux.Unlock() c.startStopMux.Lock() @@ -485,6 +492,7 @@ func (c *Client) ForceStop() { // Clear sessions immediately without trying to destroy them c.sessionsMux.Lock() c.sessions = make(map[string]*Session) + c.resettingSessions = make(map[string]struct{}) c.sessionsMux.Unlock() c.startStopMux.Lock() @@ -728,7 +736,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses // message is dispatched) so notifications for the new session id are // routed to a registered session. initializeSession := func(sessionID string) (*Session, error) { - s := newSession(sessionID, c.client, "") + s := newSession(sessionID, c, c.client, "") s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) @@ -762,12 +770,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses c.sessionsMux.Lock() c.sessions[sessionID] = s + c.sessionsMux.Unlock() if c.options.SessionFS != nil { if config.CreateSessionFSProvider == nil { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("CreateSessionFSProvider is required in session config when SessionFS is enabled in client options") } @@ -775,7 +784,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Sqlite { if _, ok := provider.(SessionFSSqliteProvider); !ok { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("SessionFS capabilities declare SQLite support but the provider does not implement SessionFSSqliteProvider") } @@ -834,7 +843,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if err != nil { if registeredSessionID != "" { c.sessionsMux.Lock() - delete(c.sessions, registeredSessionID) + c.forgetSessionLocked(registeredSessionID) c.sessionsMux.Unlock() } return nil, fmt.Errorf("failed to create session: %w", err) @@ -844,7 +853,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if err := json.Unmarshal(result, &response); err != nil { if registeredSessionID != "" { c.sessionsMux.Lock() - delete(c.sessions, registeredSessionID) + c.forgetSessionLocked(registeredSessionID) c.sessionsMux.Unlock() } return nil, fmt.Errorf("failed to unmarshal response: %w", err) @@ -856,7 +865,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if localSessionID != "" && response.SessionID != "" && response.SessionID != localSessionID { c.sessionsMux.Lock() - delete(c.sessions, registeredSessionID) + c.forgetSessionLocked(registeredSessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("session.create returned sessionId %s but the caller requested %s", response.SessionID, localSessionID) } @@ -1020,7 +1029,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. - session := newSession(sessionID, c.client, "") + session := newSession(sessionID, c, c.client, "") session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) @@ -1059,7 +1068,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if c.options.SessionFS != nil { if config.CreateSessionFSProvider == nil { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("CreateSessionFSProvider is required in session config when SessionFS is enabled in client options") } @@ -1067,7 +1076,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Sqlite { if _, ok := provider.(SessionFSSqliteProvider); !ok { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("SessionFS capabilities declare SQLite support but the provider does not implement SessionFSSqliteProvider") } @@ -1078,7 +1087,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, result, err := c.client.Request("session.resume", req) if err != nil { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to resume session: %w", err) } @@ -1086,7 +1095,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, var response resumeSessionResponse if err := json.Unmarshal(result, &response); err != nil { c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil, fmt.Errorf("failed to unmarshal response: %w", err) } @@ -1107,6 +1116,58 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, return session, nil } +func (c *Client) resetSession(ctx context.Context, session *Session, config *SessionConfig) (*ResetResult, error) { + if err := c.ensureConnected(ctx); err != nil { + return nil, err + } + if config == nil { + return nil, errors.New("reset config is required") + } + + previousSessionID := session.SessionID + c.sessionsMux.Lock() + if _, ok := c.resettingSessions[previousSessionID]; ok { + c.sessionsMux.Unlock() + return nil, fmt.Errorf("cannot reset session %s: reset is already in progress", previousSessionID) + } + if c.sessions[previousSessionID] != session { + c.sessionsMux.Unlock() + return nil, fmt.Errorf("cannot reset session %s: it is not active on this client", previousSessionID) + } + c.resettingSessions[previousSessionID] = struct{}{} + c.sessionsMux.Unlock() + defer func() { + c.sessionsMux.Lock() + delete(c.resettingSessions, previousSessionID) + c.sessionsMux.Unlock() + }() + + if _, err := session.RPC.Queue.Clear(ctx); err != nil { + return nil, fmt.Errorf("failed to clear pending queue for session %s: %w", previousSessionID, err) + } + if err := session.Disconnect(); err != nil { + return nil, err + } + + resetConfig := *config + resetConfig.SessionID = "" + freshSession, err := c.CreateSession(ctx, &resetConfig) + if err != nil { + return nil, err + } + + return &ResetResult{ + PreviousSessionID: previousSessionID, + Session: freshSession, + }, nil +} + +func (c *Client) unregisterSession(sessionID string) { + c.sessionsMux.Lock() + c.forgetSessionLocked(sessionID) + c.sessionsMux.Unlock() +} + // ListSessions returns metadata about all sessions known to the server. // // Returns a list of SessionMetadata for all available sessions, including their IDs, @@ -1219,7 +1280,7 @@ func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { // Remove from local sessions map if present c.sessionsMux.Lock() - delete(c.sessions, sessionID) + c.forgetSessionLocked(sessionID) c.sessionsMux.Unlock() return nil diff --git a/go/session.go b/go/session.go index ca67cb2c8..90bf8ccfa 100644 --- a/go/session.go +++ b/go/session.go @@ -52,6 +52,7 @@ type Session struct { // SessionID is the unique identifier for this session. SessionID string workspacePath string + parentClient *Client client *jsonrpc2.Client clientSessionAPIs *rpc.ClientSessionAPIHandlers handlers []sessionHandler @@ -296,10 +297,11 @@ func canvasResultError(err error) error { } // newSession creates a new session wrapper with the given session ID and client. -func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { +func newSession(sessionID string, parentClient *Client, client *jsonrpc2.Client, workspacePath string) *Session { s := &Session{ SessionID: sessionID, workspacePath: workspacePath, + parentClient: parentClient, client: client, clientSessionAPIs: &rpc.ClientSessionAPIHandlers{}, handlers: make([]sessionHandler, 0), @@ -1525,9 +1527,30 @@ func (s *Session) Disconnect() error { s.elicitationHandler = nil s.elicitationMu.Unlock() + if s.parentClient != nil { + s.parentClient.unregisterSession(s.SessionID) + } + return nil } +// Reset closes this conversation and creates a fresh session from config. +// +// The returned session is the one callers should use going forward. The current +// session object is disconnected after a successful reset. The SDK does not +// clear host-owned UI state, local drafts, or app persistence. If reset fails +// after teardown starts, treat the old session as no longer usable and create +// or resume another session explicitly. +// +// Any SessionID in config is ignored so the reset always creates a fresh runtime +// session identity. +func (s *Session) Reset(ctx context.Context, config *SessionConfig) (*ResetResult, error) { + if s.parentClient == nil { + return nil, fmt.Errorf("cannot reset session %s: it is not attached to its creating client", s.SessionID) + } + return s.parentClient.resetSession(ctx, s, config) +} + // Abort aborts the currently processing message in this session. // // Use this to cancel a long-running request. The session remains valid diff --git a/go/types.go b/go/types.go index 7ffd454a3..62bc7ffa6 100644 --- a/go/types.go +++ b/go/types.go @@ -1108,6 +1108,15 @@ type SessionConfig struct { // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo } + +// ResetResult is returned by [Session.Reset]. +type ResetResult struct { + // PreviousSessionID is the session ID that was closed and replaced. + PreviousSessionID string + // Session is the fresh session created from the supplied reset configuration. + Session *Session +} + type Tool struct { Name string `json:"name"` Description string `json:"description,omitempty"` diff --git a/java/README.md b/java/README.md index dd778271b..f1dc6d535 100644 --- a/java/README.md +++ b/java/README.md @@ -130,6 +130,20 @@ Or run it directly from the repository: jbang https://github.com/github/copilot-sdk/blob/main/java/jbang-example.java ``` +## Resetting a Session + +Use `session.resetAsync(config)` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```java +var result = session.resetAsync(new SessionConfig() + .setModel("gpt-5") + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); +session = result.session(); +// Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `previousSessionId` identifies the abandoned session. The old `CopilotSession` object is closed after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ## Using experimental APIs Some SDK APIs are marked as experimental with `@CopilotExperimental`. These APIs may change or be removed in future versions without notice. @@ -272,4 +286,3 @@ mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test- ## License MIT — see [LICENSE](LICENSE) for details. - diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 50137aefe..8e6c4ab5d 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -10,6 +10,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -102,6 +103,7 @@ public final class CopilotClient implements AutoCloseable { private final CliServerManager serverManager; private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); + private final Set resettingSessions = ConcurrentHashMap.newKeySet(); private volatile CompletableFuture connectionFuture; private volatile boolean disposed = false; private final String optionsHost; @@ -361,6 +363,7 @@ public CompletableFuture stop() { closeFutures.add(future); } sessions.clear(); + resettingSessions.clear(); return CompletableFuture.allOf(closeFutures.toArray(new CompletableFuture[0])) .thenCompose(v -> cleanupConnection()); @@ -374,6 +377,7 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); + resettingSessions.clear(); // Dispatch the blocking shutdownOwnedExecutor() on a dedicated thread: // cleanupConnection() is chained off async work running on the owned // executor, so a plain whenComplete(...) here could land the awaitTermination @@ -489,7 +493,7 @@ public CompletableFuture createSession(SessionConfig config) { // sessions map. java.util.function.Function initializeSession = sid -> { long setupNanos = System.nanoTime(); - var s = new CopilotSession(sid, connection.rpc); + var s = new CopilotSession(sid, connection.rpc, null, this); s.setExecutor(executor); SessionRequestBuilder.configureSession(s, config); if (extracted.transformCallbacks() != null) { @@ -638,7 +642,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS long totalNanos = System.nanoTime(); // Register the session before the RPC call to avoid missing early events. long setupNanos = System.nanoTime(); - var session = new CopilotSession(sessionId, connection.rpc); + var session = new CopilotSession(sessionId, connection.rpc, null, this); session.setExecutor(executor); SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); @@ -737,6 +741,37 @@ public CompletableFuture resumeSession(String sessionId, ResumeS }); } + CompletableFuture resetSession(CopilotSession session, SessionConfig config) { + if (config == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException("config cannot be null")); + } + String previousSessionId = session.getSessionId(); + if (!resettingSessions.add(previousSessionId)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot reset session " + previousSessionId + ": reset is already in progress.")); + } + if (sessions.get(previousSessionId) != session) { + resettingSessions.remove(previousSessionId); + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot reset session " + previousSessionId + ": it is not active on this client.")); + } + + try { + SessionConfig resetConfig = config.clone().setSessionId(null); + return session.getRpc().queue.clear().thenCompose(v -> session.destroyForResetAsync()) + .thenCompose(v -> createSession(resetConfig)) + .thenApply(freshSession -> new ResetSessionResult(previousSessionId, freshSession)) + .whenComplete((ignored, error) -> resettingSessions.remove(previousSessionId)); + } catch (RuntimeException | Error ex) { + resettingSessions.remove(previousSessionId); + return CompletableFuture.failedFuture(ex); + } + } + + void unregisterSession(String sessionId) { + sessions.remove(sessionId); + } + /** * Applies the post-create / post-resume {@code session.options.update} patch. *

@@ -1040,6 +1075,7 @@ public CompletableFuture deleteSession(String sessionId) { throw new RuntimeException("Failed to delete session " + sessionId + ": " + response.error()); } sessions.remove(sessionId); + resettingSessions.remove(sessionId); })); } diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index fa080c925..4c928db6f 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -92,6 +92,7 @@ import com.github.copilot.rpc.SendMessageResponse; import com.github.copilot.rpc.SessionCapabilities; import com.github.copilot.rpc.SessionEndHookInput; +import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; import com.github.copilot.rpc.SessionStartHookInput; import com.github.copilot.rpc.SessionUiApi; @@ -164,6 +165,7 @@ public final class CopilotSession implements AutoCloseable { private final Object openCanvasesLock = new Object(); private final List openCanvases = new ArrayList<>(); private final SessionUiApi ui; + private final CopilotClient parentClient; private final JsonRpcClient rpc; private volatile SessionRpc sessionRpc; private final Set> eventHandlers = ConcurrentHashMap.newKeySet(); @@ -196,7 +198,7 @@ public final class CopilotSession implements AutoCloseable { * the JSON-RPC client for communication */ CopilotSession(String sessionId, JsonRpcClient rpc) { - this(sessionId, rpc, null); + this(sessionId, rpc, null, null); } /** @@ -213,7 +215,12 @@ public final class CopilotSession implements AutoCloseable { * the workspace path if infinite sessions are enabled */ CopilotSession(String sessionId, JsonRpcClient rpc, String workspacePath) { + this(sessionId, rpc, workspacePath, null); + } + + CopilotSession(String sessionId, JsonRpcClient rpc, String workspacePath, CopilotClient parentClient) { this.sessionId = sessionId; + this.parentClient = parentClient; this.rpc = rpc; this.workspacePath = workspacePath; this.ui = new SessionUiApiImpl(); @@ -325,6 +332,7 @@ public SessionRpc getRpc() { if (rpc == null) { throw new IllegalStateException("Session is not connected — RPC client is unavailable"); } + SessionRpc current = sessionRpc; if (current == null) { synchronized (this) { @@ -337,6 +345,28 @@ public SessionRpc getRpc() { return current; } + /** + * Resets this conversation by closing the underlying runtime session and + * creating a fresh session from {@code config}. + *

+ * Use the returned session for subsequent work. The SDK does not clear + * host-owned UI state, local drafts, or app persistence. If reset fails after + * teardown starts, treat the old session as no longer usable and create or + * resume another session explicitly. + * + * @param config + * configuration for the replacement session; any session ID is + * ignored + * @return a future resolving to the fresh session and previous session ID + */ + public CompletableFuture resetAsync(SessionConfig config) { + if (parentClient == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Cannot reset a session that is not attached to its creating client.")); + } + return parentClient.resetSession(this, config); + } + /** * Sets a custom error handler for exceptions thrown by event handlers. *

@@ -2118,21 +2148,56 @@ private void ensureNotTerminated() { */ @Override public void close() { + var destroy = destroyForCloseAsync(); + try { + destroy.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + forceLocalClose(); + LOG.log(Level.FINE, "Error destroying session", e); + } + } + + CompletableFuture destroyForResetAsync() { + return destroyAsync(false); + } + + private CompletableFuture destroyForCloseAsync() { + return destroyAsync(true); + } + + private CompletableFuture destroyAsync(boolean cleanupOnFailure) { synchronized (this) { if (isTerminated) { - return; // Already terminated - no-op + return CompletableFuture.completedFuture(null); } isTerminated = true; } - timeoutScheduler.shutdownNow(); + if (cleanupOnFailure) { + forceLocalClose(); + } - try { - rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class).get(5, TimeUnit.SECONDS); - } catch (Exception e) { - LOG.log(Level.FINE, "Error destroying session", e); + return rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class) + .whenComplete((ignored, error) -> { + if (error == null && !cleanupOnFailure) { + forceLocalClose(); + } else if (error != null && !cleanupOnFailure) { + synchronized (this) { + isTerminated = false; + } + } + }); + } + + private void forceLocalClose() { + timeoutScheduler.shutdownNow(); + clearLocalState(); + if (parentClient != null) { + parentClient.unregisterSession(sessionId); } + } + private void clearLocalState() { eventHandlers.clear(); toolHandlers.clear(); commandHandlers.clear(); diff --git a/java/src/main/java/com/github/copilot/ResetSessionResult.java b/java/src/main/java/com/github/copilot/ResetSessionResult.java new file mode 100644 index 000000000..94be2d585 --- /dev/null +++ b/java/src/main/java/com/github/copilot/ResetSessionResult.java @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +/** + * Result returned by + * {@link CopilotSession#resetAsync(com.github.copilot.rpc.SessionConfig)}. + * + * @param previousSessionId + * the session ID that was closed and replaced + * @param session + * the fresh session created from the supplied reset configuration + */ +public record ResetSessionResult(String previousSessionId, CopilotSession session) { +} diff --git a/java/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/src/test/java/com/github/copilot/ConfigCloneTest.java index 81c937dbe..3aae79b0a 100644 --- a/java/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -24,6 +24,7 @@ import com.github.copilot.rpc.LargeToolOutputConfig; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.ModelInfo; +import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SystemMessageConfig; @@ -134,6 +135,19 @@ void sessionConfigCloneBasic() { assertEquals(original.isStreaming(), cloned.isStreaming()); } + @Test + void sessionConfigCloneCanClearSessionIdForResetWithoutMutatingSource() { + SessionConfig original = new SessionConfig().setSessionId("old-session").setModel("gpt-5") + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + + SessionConfig resetConfig = original.clone().setSessionId(null); + + assertEquals("old-session", original.getSessionId()); + assertNull(resetConfig.getSessionId()); + assertEquals("gpt-5", resetConfig.getModel()); + assertSame(original.getOnPermissionRequest(), resetConfig.getOnPermissionRequest()); + } + @Test void sessionConfigListIndependence() { SessionConfig original = new SessionConfig(); diff --git a/nodejs/README.md b/nodejs/README.md index 4219d3bc2..dd3413159 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -506,6 +506,21 @@ When the user types `/deploy staging` in the CLI, the SDK receives a `command.ex Commands are sent to the CLI on both `createSession` and `resumeSession`, so you can update the command set when resuming. +### Resetting a Session + +Use `session.reset(config)` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```ts +const result = await session.reset({ + model: "gpt-5", + onPermissionRequest, +}); +session = result.session; +// Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `previousSessionId` identifies the abandoned session. The old `CopilotSession` object is disconnected after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ### UI Elicitation When the session has elicitation support — either from the CLI's TUI or from another client that registered an `onElicitationRequest` handler (see [Elicitation Requests](#elicitation-requests)) — the SDK can request interactive form dialogs from the user. The `session.ui` object provides convenience methods built on a single generic `elicitation` RPC. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8dc35b8d7..a6277005b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -52,6 +52,7 @@ import type { LargeToolOutputConfig, MCPServerConfig, ModelInfo, + ResetSessionResult, ResumeSessionConfig, SectionTransformFn, SessionConfig, @@ -304,6 +305,7 @@ export class CopilotClient { private actualHost: string = "localhost"; private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected"; private sessions: Map = new Map(); + private resettingSessions: Set = new Set(); private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ private connectionConfig: InternalRuntimeConnection; @@ -561,6 +563,10 @@ export class CopilotClient { session.clientSessionApis.sessionFs = createSessionFsAdapter(provider); } + private forgetSession(sessionId: string): void { + this.sessions.delete(sessionId); + } + /** * Starts the CLI server and establishes a connection. * @@ -672,6 +678,7 @@ export class CopilotClient { } } this.sessions.clear(); + this.resettingSessions.clear(); // Close connection if (this.connection) { @@ -792,6 +799,7 @@ export class CopilotClient { // Clear sessions immediately without trying to destroy them this.sessions.clear(); + this.resettingSessions.clear(); // Force close connection if (this.connection) { @@ -1046,7 +1054,9 @@ export class CopilotClient { sessionId, this.connection!, undefined, - this.onGetTraceContext + this.onGetTraceContext, + (sessionToReset, config) => this.resetSession(sessionToReset, config), + (sessionIdToForget) => this.forgetSession(sessionIdToForget) ); s.registerTools(config.tools); s.registerCanvases(config.canvases); @@ -1186,11 +1196,10 @@ export class CopilotClient { } session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); - await this.updateSessionOptionsForMode(session, config); } catch (e) { if (registeredId !== undefined) { - this.sessions.delete(registeredId); + this.forgetSession(registeredId); } throw e; } @@ -1233,7 +1242,9 @@ export class CopilotClient { sessionId, this.connection!, undefined, - this.onGetTraceContext + this.onGetTraceContext, + (sessionToReset, config) => this.resetSession(sessionToReset, config), + (sessionIdToForget) => this.forgetSession(sessionIdToForget) ); session.registerTools(config.tools); session.registerCanvases(config.canvases); @@ -1354,16 +1365,47 @@ export class CopilotClient { session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); session.setOpenCanvases(openCanvases ?? []); - await this.updateSessionOptionsForMode(session, config); } catch (e) { - this.sessions.delete(sessionId); + this.forgetSession(sessionId); throw e; } return session; } + private async resetSession( + session: CopilotSession, + config: SessionConfig + ): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + + const previousSessionId = session.sessionId; + if (this.resettingSessions.has(previousSessionId)) { + throw new Error( + `Cannot reset session ${previousSessionId}: reset is already in progress.` + ); + } + if (this.sessions.get(previousSessionId) !== session) { + throw new Error( + `Cannot reset session ${previousSessionId}: it is not active on this client.` + ); + } + + this.resettingSessions.add(previousSessionId); + try { + await session.rpc.queue.clear(); + await session.disconnect(); + + const freshSession = await this.createSession({ ...config, sessionId: undefined }); + return { previousSessionId, session: freshSession }; + } finally { + this.resettingSessions.delete(previousSessionId); + } + } + /** * Sends a ping request to the server to verify connectivity. * @@ -1593,7 +1635,7 @@ export class CopilotClient { } // Remove from local sessions map if present - this.sessions.delete(sessionId); + this.forgetSession(sessionId); } /** diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c044f2b94..5545305d7 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -49,6 +49,7 @@ export type { CommandHandler, CloudSessionOptions, CloudSessionRepository, + ResetSessionResult, AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8ae19755a..f96f542c1 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -19,6 +19,7 @@ import type { AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, + ResetSessionResult, ElicitationHandler, ElicitationParams, ElicitationResult, @@ -30,6 +31,7 @@ import type { MessageOptions, PermissionHandler, PermissionRequest, + SessionConfig, ContextTier, ReasoningEffort, ReasoningSummary, @@ -53,6 +55,12 @@ import type { UserInputResponse, } from "./types.js"; +type ResetSessionDelegate = ( + session: CopilotSession, + config: SessionConfig +) => Promise; +type UnregisterSessionDelegate = (sessionId: string) => void; + /** * Convert a raw hook input received over the wire into its public-facing shape. * This deserializes the numeric Unix-ms `timestamp` field on BaseHookInput @@ -134,6 +142,8 @@ export class CopilotSession { private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; private openCanvasInstances: OpenCanvasInstance[] = []; + private resetDelegate?: ResetSessionDelegate; + private unregisterDelegate?: UnregisterSessionDelegate; /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ clientSessionApis: ClientSessionApiHandlers = {}; @@ -151,9 +161,45 @@ export class CopilotSession { public readonly sessionId: string, private connection: MessageConnection, private _workspacePath?: string, - traceContextProvider?: TraceContextProvider + traceContextProvider?: TraceContextProvider, + resetDelegate?: ResetSessionDelegate, + unregisterDelegate?: UnregisterSessionDelegate ) { this.traceContextProvider = traceContextProvider; + this.resetDelegate = resetDelegate; + this.unregisterDelegate = unregisterDelegate; + } + + /** @internal */ + setResetDelegate(resetDelegate: ResetSessionDelegate): void { + this.resetDelegate = resetDelegate; + } + + private clearLocalState(): void { + this.eventHandlers.clear(); + this.typedEventHandlers.clear(); + this.toolHandlers.clear(); + this.canvases.clear(); + this.commandHandlers.clear(); + this.permissionHandler = undefined; + this.userInputHandler = undefined; + this.elicitationHandler = undefined; + this.exitPlanModeHandler = undefined; + this.autoModeSwitchHandler = undefined; + this.hooks = undefined; + this.transformCallbacks = undefined; + this.traceContextProvider = undefined; + this._capabilities = {}; + this.openCanvasInstances = []; + this.clientSessionApis = {}; + this.resetDelegate = undefined; + this.unregisterDelegate = undefined; + this._rpc = null; + } + + /** @internal */ + detachAfterReset(): void { + this.clearLocalState(); } /** @@ -1170,17 +1216,24 @@ export class CopilotSession { * ``` */ async disconnect(): Promise { - await this.connection.sendRequest("session.destroy", { - sessionId: this.sessionId, - }); - this.eventHandlers.clear(); - this.typedEventHandlers.clear(); - this.toolHandlers.clear(); - this.permissionHandler = undefined; - this.userInputHandler = undefined; - this.elicitationHandler = undefined; - this.exitPlanModeHandler = undefined; - this.autoModeSwitchHandler = undefined; + const unregisterDelegate = this.unregisterDelegate; + try { + const response = await this.connection.sendRequest("session.destroy", { + sessionId: this.sessionId, + }); + const { success, error } = response as { success?: boolean; error?: string }; + if (success === false) { + throw new Error( + `Failed to destroy session ${this.sessionId}: ${error || "Unknown error"}` + ); + } + } finally { + try { + unregisterDelegate?.(this.sessionId); + } finally { + this.clearLocalState(); + } + } } /** Enables `await using session = ...` syntax for automatic cleanup. */ @@ -1214,6 +1267,37 @@ export class CopilotSession { }); } + /** + * Resets this conversation by closing the underlying runtime session and + * creating a fresh session from the supplied configuration. + * + * The returned session is the one callers should use going forward. The + * current session object is detached after a successful reset. + * + * The SDK does not clear host-owned UI state, local drafts, or app + * persistence. Clear those after this method resolves successfully. If + * reset fails after teardown starts, treat the old session as no longer + * usable and create or resume another session explicitly. + * + * Any `sessionId` on the supplied config is ignored so the reset always + * creates a fresh runtime session identity. + * + * @param config - Configuration for the replacement session + * @returns The fresh session and the ID of the session that was replaced + * @throws Error if this session is no longer attached to its creating client + * + * @example + * ```typescript + * const { session: freshSession } = await session.reset(config); + * ``` + */ + async reset(config: SessionConfig): Promise { + if (!this.resetDelegate) { + throw new Error("Cannot reset a session that is not attached to its creating client."); + } + return this.resetDelegate(this, config); + } + /** * Change the model for this session. * The new model takes effect for the next message. Conversation history is preserved. diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 75aa5159f..39b419882 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2037,6 +2037,21 @@ export interface SessionConfig extends SessionConfigBase { cloud?: CloudSessionOptions; } +/** + * Result returned by {@link CopilotSession.reset}. + */ +export interface ResetSessionResult { + /** + * The session ID that was closed and replaced. + */ + previousSessionId: string; + + /** + * The fresh session created from the supplied reset configuration. + */ + session: CopilotSession; +} + /** * Configuration for resuming an existing session via * {@link CopilotClient.resumeSession}. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 9352eb627..153c605d6 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -13,6 +13,43 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { + it.each([ + { + name: "transport failure", + sendRequest: async () => { + throw new Error("transport down"); + }, + expectedError: /transport down/, + }, + { + name: "destroy failure response", + sendRequest: async () => ({ success: false, error: "destroy failed" }), + expectedError: /Failed to destroy session session-1: destroy failed/, + }, + ])("disconnect clears local state and unregisters after $name", async (scenario) => { + const sendRequest = vi.fn(scenario.sendRequest); + const unregister = vi.fn(); + const session = new CopilotSession( + "session-1", + { sendRequest } as any, + undefined, + undefined, + vi.fn(), + unregister + ); + session.registerPermissionHandler(vi.fn()); + session.registerTools([{ name: "cleanup-tool", handler: vi.fn() }] as any); + + await expect(session.disconnect()).rejects.toThrow(scenario.expectedError); + + expect(sendRequest).toHaveBeenCalledWith("session.destroy", { sessionId: "session-1" }); + expect(unregister).toHaveBeenCalledWith("session-1"); + expect((session as any).permissionHandler).toBeUndefined(); + expect(session.getToolHandler("cleanup-tool")).toBeUndefined(); + expect((session as any).resetDelegate).toBeUndefined(); + expect((session as any).unregisterDelegate).toBeUndefined(); + }); + it("does not respond to v3 permission requests when handler returns no-result", async () => { const session = new CopilotSession("session-1", {} as any); session.registerPermissionHandler(() => ({ kind: "no-result" })); @@ -106,6 +143,148 @@ describe("CopilotClient", () => { expect(payload.openCanvasInstances).toBeUndefined(); }); + it("reset closes the current session and returns a fresh session from explicit config", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.queue.clear") return undefined; + if (method === "session.destroy") return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ + sessionId: "original-session", + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); + + const result = await session.reset({ + sessionId: "ignored-reset-id", + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); + + expect(result.previousSessionId).toBe("original-session"); + expect(result.session.sessionId).not.toBe("original-session"); + await expect(session.reset({ onPermissionRequest: approveAll })).rejects.toThrow( + /not attached to its creating client/ + ); + + const calls = spy.mock.calls.map(([method, params]) => ({ method, params })); + expect(calls.map(({ method }) => method)).toEqual([ + "session.create", + "session.queue.clear", + "session.destroy", + "session.create", + ]); + expect(calls[1].params).toEqual({ sessionId: "original-session" }); + expect(calls[2].params).toEqual({ sessionId: "original-session" }); + expect(calls[3].params.sessionId).not.toBe("original-session"); + expect(calls[3].params.sessionId).not.toBe("ignored-reset-id"); + expect(calls[3].params.model).toBe("claude-sonnet-4.5"); + expect(spy).not.toHaveBeenCalledWith("session.abort", expect.anything()); + expect(spy).not.toHaveBeenCalledWith("session.delete", expect.anything()); + expect(spy).not.toHaveBeenCalledWith("session.shutdown", expect.anything()); + }); + + it("reset uses the supplied create config for resumed sessions", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.queue.clear") return undefined; + if (method === "session.destroy") return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.resumeSession("resumed-session", { + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + suppressResumeEvent: true, + continuePendingWork: true, + openCanvases: [], + }); + + const result = await session.reset({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); + + expect(result.previousSessionId).toBe("resumed-session"); + expect(result.session.sessionId).not.toBe("resumed-session"); + + const createPayload = spy.mock.calls + .filter(([method]) => method === "session.create") + .at(-1)![1] as any; + expect(createPayload.sessionId).not.toBe("resumed-session"); + expect(createPayload.model).toBe("claude-sonnet-4.5"); + expect(createPayload.suppressResumeEvent).toBeUndefined(); + expect(createPayload.continuePendingWork).toBeUndefined(); + expect(createPayload.openCanvases).toBeUndefined(); + expect(createPayload.disableResume).toBeUndefined(); + }); + + it("reset rejects concurrent calls for the same session", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + let releaseQueueClear!: () => void; + let queueClearStarted!: () => void; + const queueClearStartedPromise = new Promise((resolve) => { + queueClearStarted = resolve; + }); + const queueClearPromise = new Promise((resolve) => { + releaseQueueClear = resolve; + }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.queue.clear") { + queueClearStarted(); + await queueClearPromise; + return undefined; + } + if (method === "session.destroy") return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ + sessionId: "concurrent-clear-session", + onPermissionRequest: approveAll, + }); + + const firstReset = session.reset({ onPermissionRequest: approveAll }); + await queueClearStartedPromise; + + await expect(session.reset({ onPermissionRequest: approveAll })).rejects.toThrow( + /reset is already in progress/ + ); + + releaseQueueClear(); + await expect(firstReset).resolves.toMatchObject({ + previousSessionId: "concurrent-clear-session", + }); + + expect(spy.mock.calls.filter(([method]) => method === "session.queue.clear")).toHaveLength( + 1 + ); + expect(spy.mock.calls.filter(([method]) => method === "session.destroy")).toHaveLength(1); + expect(spy.mock.calls.filter(([method]) => method === "session.create")).toHaveLength(2); + }); + it("forwards reasoningSummary in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/README.md b/python/README.md index a916f98ec..bedf9fa6d 100644 --- a/python/README.md +++ b/python/README.md @@ -791,6 +791,21 @@ async with await client.create_session( Commands can also be provided when resuming a session via `resume_session(commands=[...])`. +## Resetting a Session + +Use `await session.reset(**config)` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```python +result = await session.reset( + on_permission_request=PermissionHandler.approve_all, + model="gpt-5", +) +session = result.session +# Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `previous_session_id` identifies the abandoned session. The old `CopilotSession` object is disconnected after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ## UI Elicitation The `session.ui` API provides convenience methods for asking the user questions through interactive dialogs. These methods are only available when the CLI host supports elicitation — check `session.capabilities` before calling. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 3f1a84d25..36c039fd6 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -16,6 +16,7 @@ CopilotClientMode, ToolSet, ) +from ._reset import ResetSessionResult from .canvas import ( CanvasAction, CanvasDeclaration, @@ -232,6 +233,7 @@ "PreToolUseHookOutput", "ProviderConfig", "ReasoningSummary", + "ResetSessionResult", "RemoteSessionMode", "RuntimeConnection", "rpc", diff --git a/python/copilot/_model_capabilities.py b/python/copilot/_model_capabilities.py new file mode 100644 index 000000000..6a0781327 --- /dev/null +++ b/python/copilot/_model_capabilities.py @@ -0,0 +1,65 @@ +"""Model capability override types.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ModelVisionLimitsOverride: + supported_media_types: list[str] | None = None + max_prompt_images: int | None = None + max_prompt_image_size: int | None = None + + +@dataclass +class ModelLimitsOverride: + max_prompt_tokens: int | None = None + max_output_tokens: int | None = None + max_context_window_tokens: int | None = None + vision: ModelVisionLimitsOverride | None = None + + +@dataclass +class ModelSupportsOverride: + vision: bool | None = None + reasoning_effort: bool | None = None + + +@dataclass +class ModelCapabilitiesOverride: + supports: ModelSupportsOverride | None = None + limits: ModelLimitsOverride | None = None + + +def capabilities_to_dict(caps: ModelCapabilitiesOverride) -> dict: + result: dict = {} + if caps.supports is not None: + supports: dict = {} + if caps.supports.vision is not None: + supports["vision"] = caps.supports.vision + if caps.supports.reasoning_effort is not None: + supports["reasoningEffort"] = caps.supports.reasoning_effort + if supports: + result["supports"] = supports + if caps.limits is not None: + limits: dict = {} + if caps.limits.max_prompt_tokens is not None: + limits["max_prompt_tokens"] = caps.limits.max_prompt_tokens + if caps.limits.max_output_tokens is not None: + limits["max_output_tokens"] = caps.limits.max_output_tokens + if caps.limits.max_context_window_tokens is not None: + limits["max_context_window_tokens"] = caps.limits.max_context_window_tokens + if caps.limits.vision is not None: + vision: dict = {} + if caps.limits.vision.supported_media_types is not None: + vision["supported_media_types"] = caps.limits.vision.supported_media_types + if caps.limits.vision.max_prompt_images is not None: + vision["max_prompt_images"] = caps.limits.vision.max_prompt_images + if caps.limits.vision.max_prompt_image_size is not None: + vision["max_prompt_image_size"] = caps.limits.vision.max_prompt_image_size + if vision: + limits["vision"] = vision + if limits: + result["limits"] = limits + return result diff --git a/python/copilot/_reset.py b/python/copilot/_reset.py new file mode 100644 index 000000000..0bec46dff --- /dev/null +++ b/python/copilot/_reset.py @@ -0,0 +1,17 @@ +"""Session reset result types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ResetSessionResult: + """Result returned by :meth:`copilot.session.CopilotSession.reset`.""" + + previous_session_id: str + """The session ID that was closed and replaced.""" + + session: Any + """The fresh session created from the supplied reset configuration.""" diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dcec6e8f..a2762aa16 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -32,6 +32,7 @@ from types import TracebackType from typing import Any, ClassVar, Literal, TypedDict, cast, overload +from . import _model_capabilities from ._diagnostics import log_timing from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError from ._mode import ( @@ -53,6 +54,7 @@ _system_message_for_mode, _validate_tool_filter_list, ) +from ._reset import ResetSessionResult from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context from .canvas import ( @@ -102,6 +104,12 @@ logger = logging.getLogger(__name__) +ModelCapabilitiesOverride = _model_capabilities.ModelCapabilitiesOverride +ModelLimitsOverride = _model_capabilities.ModelLimitsOverride +ModelSupportsOverride = _model_capabilities.ModelSupportsOverride +ModelVisionLimitsOverride = _model_capabilities.ModelVisionLimitsOverride +_capabilities_to_dict = _model_capabilities.capabilities_to_dict + # ============================================================================ # Connection Types # ============================================================================ @@ -582,66 +590,6 @@ def to_dict(self) -> dict: return result -@dataclass -class ModelVisionLimitsOverride: - supported_media_types: list[str] | None = None - max_prompt_images: int | None = None - max_prompt_image_size: int | None = None - - -@dataclass -class ModelLimitsOverride: - max_prompt_tokens: int | None = None - max_output_tokens: int | None = None - max_context_window_tokens: int | None = None - vision: ModelVisionLimitsOverride | None = None - - -@dataclass -class ModelSupportsOverride: - vision: bool | None = None - reasoning_effort: bool | None = None - - -@dataclass -class ModelCapabilitiesOverride: - supports: ModelSupportsOverride | None = None - limits: ModelLimitsOverride | None = None - - -def _capabilities_to_dict(caps: ModelCapabilitiesOverride) -> dict: - result: dict = {} - if caps.supports is not None: - s: dict = {} - if caps.supports.vision is not None: - s["vision"] = caps.supports.vision - if caps.supports.reasoning_effort is not None: - s["reasoningEffort"] = caps.supports.reasoning_effort - if s: - result["supports"] = s - if caps.limits is not None: - lim: dict = {} - if caps.limits.max_prompt_tokens is not None: - lim["max_prompt_tokens"] = caps.limits.max_prompt_tokens - if caps.limits.max_output_tokens is not None: - lim["max_output_tokens"] = caps.limits.max_output_tokens - if caps.limits.max_context_window_tokens is not None: - lim["max_context_window_tokens"] = caps.limits.max_context_window_tokens - if caps.limits.vision is not None: - v: dict = {} - if caps.limits.vision.supported_media_types is not None: - v["supported_media_types"] = caps.limits.vision.supported_media_types - if caps.limits.vision.max_prompt_images is not None: - v["max_prompt_images"] = caps.limits.vision.max_prompt_images - if caps.limits.vision.max_prompt_image_size is not None: - v["max_prompt_image_size"] = caps.limits.vision.max_prompt_image_size - if v: - lim["vision"] = v - if lim: - result["limits"] = lim - return result - - @dataclass class ModelPolicy: """Model policy state""" @@ -1230,6 +1178,7 @@ def __init__( self._state: _ConnectionState = "disconnected" self._sessions: dict[str, CopilotSession] = {} self._sessions_lock = threading.Lock() + self._resetting_sessions: set[str] = set() self._models_cache: list[ModelInfo] | None = None self._models_cache_lock = asyncio.Lock() self._lifecycle_handlers: list[SessionLifecycleHandler] = [] @@ -1462,6 +1411,7 @@ async def stop(self) -> None: with self._sessions_lock: sessions_to_destroy = list(self._sessions.values()) self._sessions.clear() + self._resetting_sessions.clear() for session in sessions_to_destroy: try: @@ -1521,6 +1471,7 @@ async def force_stop(self) -> None: # Clear sessions immediately without trying to destroy them with self._sessions_lock: self._sessions.clear() + self._resetting_sessions.clear() # Close the transport first to signal the server immediately. # For external servers (TCP), this closes the socket. @@ -1987,7 +1938,13 @@ def _initialize_session(sid: str) -> CopilotSession: to a registered session. """ setup_start = time.perf_counter() - s = CopilotSession(sid, self._client, workspace_path=None) + s = CopilotSession( + sid, + self._client, + workspace_path=None, + reset_callback=self._reset_session, + unregister_callback=self._forget_session, + ) if self._session_fs_config: if create_session_fs_handler is None: raise ValueError( @@ -2510,7 +2467,13 @@ async def resume_session( # Create and register the session before issuing the RPC so that # events emitted by the CLI (e.g. session.start) are not dropped. setup_start = time.perf_counter() - session = CopilotSession(session_id, self._client, workspace_path=None) + session = CopilotSession( + session_id, + self._client, + workspace_path=None, + reset_callback=self._reset_session, + unregister_callback=self._forget_session, + ) if self._session_fs_config: if create_session_fs_handler is None: raise ValueError( @@ -2610,6 +2573,44 @@ async def resume_session( ) return session + async def _reset_session( + self, session: CopilotSession, config: dict[str, Any] + ) -> ResetSessionResult: + if not self._client: + raise RuntimeError("Client not connected") + + previous_session_id = session.session_id + with self._sessions_lock: + if previous_session_id in self._resetting_sessions: + raise RuntimeError( + f"Cannot reset session {previous_session_id}: reset is already in progress." + ) + if self._sessions.get(previous_session_id) is not session: + raise RuntimeError( + f"Cannot reset session {previous_session_id}: it is not active on this client." + ) + self._resetting_sessions.add(previous_session_id) + + try: + await session.rpc.queue.clear() + + await session._disconnect_for_reset() + + reset_config = config.copy() + reset_config.pop("session_id", None) + fresh_session = await self.create_session(**reset_config) + return ResetSessionResult( + previous_session_id=previous_session_id, + session=fresh_session, + ) + finally: + with self._sessions_lock: + self._resetting_sessions.discard(previous_session_id) + + def _forget_session(self, session_id: str) -> None: + with self._sessions_lock: + self._sessions.pop(session_id, None) + async def ping(self, message: str | None = None) -> PingResponse: """ Send a ping request to the server to verify connectivity. diff --git a/python/copilot/session.py b/python/copilot/session.py index 32201870c..f450689f5 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -24,6 +24,8 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcError, ProcessExitedError +from ._model_capabilities import ModelCapabilitiesOverride, capabilities_to_dict +from ._reset import ResetSessionResult from ._telemetry import get_trace_context, trace_context from .canvas import CanvasError, CanvasHandler, OpenCanvasInstance from .generated.rpc import ( @@ -83,12 +85,15 @@ if TYPE_CHECKING: - from .client import ModelCapabilitiesOverride from .session_fs_provider import SessionFsProvider # Re-export SessionEvent under an alias used internally SessionEventTypeAlias = SessionEvent + +_ResetSessionCallback = Callable[["CopilotSession", dict[str, Any]], Awaitable[ResetSessionResult]] +_UnregisterSessionCallback = Callable[[str], None] + # ============================================================================ # Reasoning Effort # ============================================================================ @@ -1097,7 +1102,12 @@ class CopilotSession: """ def __init__( - self, session_id: str, client: Any, workspace_path: os.PathLike[str] | str | None = None + self, + session_id: str, + client: Any, + workspace_path: os.PathLike[str] | str | None = None, + reset_callback: _ResetSessionCallback | None = None, + unregister_callback: _UnregisterSessionCallback | None = None, ): """ Initialize a new CopilotSession. @@ -1143,6 +1153,8 @@ def __init__( self._open_canvases_lock = threading.Lock() self._rpc: SessionRpc | None = None self._destroyed = False + self._reset_callback = reset_callback + self._unregister_callback = unregister_callback @property def rpc(self) -> SessionRpc: @@ -2352,21 +2364,70 @@ async def disconnect(self) -> None: try: await self._client.request("session.destroy", {"sessionId": self.session_id}) finally: - # Clear handlers even if the request fails. + self._clear_local_state_after_disconnect() + + async def _disconnect_for_reset(self) -> None: + with self._event_handlers_lock: + if self._destroyed: + return + self._destroyed = True + + try: + await self._client.request("session.destroy", {"sessionId": self.session_id}) + except Exception: with self._event_handlers_lock: - self._event_handlers.clear() - with self._tool_handlers_lock: - self._tool_handlers.clear() - with self._permission_handler_lock: - self._permission_handler = None - with self._command_handlers_lock: - self._command_handlers.clear() - with self._elicitation_handler_lock: - self._elicitation_handler = None - with self._exit_plan_mode_handler_lock: - self._exit_plan_mode_handler = None - with self._auto_mode_switch_handler_lock: - self._auto_mode_switch_handler = None + self._destroyed = False + raise + + self._clear_local_state_after_disconnect() + + def _clear_local_state_after_disconnect(self) -> None: + with self._event_handlers_lock: + self._event_handlers.clear() + with self._tool_handlers_lock: + self._tool_handlers.clear() + with self._permission_handler_lock: + self._permission_handler = None + with self._command_handlers_lock: + self._command_handlers.clear() + with self._elicitation_handler_lock: + self._elicitation_handler = None + with self._exit_plan_mode_handler_lock: + self._exit_plan_mode_handler = None + with self._auto_mode_switch_handler_lock: + self._auto_mode_switch_handler = None + self._reset_callback = None + if self._unregister_callback is not None: + self._unregister_callback(self.session_id) + self._unregister_callback = None + + async def reset(self, **config: Any) -> ResetSessionResult: + """ + Reset this conversation by closing the underlying runtime session and + creating a fresh session from the supplied configuration. + + The returned session is the one callers should use going forward. The + current session object is detached after a successful reset. + + The SDK does not clear host-owned UI state, local drafts, or app + persistence. Clear those after this method resolves successfully. + If reset fails after teardown starts, treat the old session as no + longer usable and create or resume another session explicitly. + Any ``session_id`` in ``config`` is ignored so the reset always creates + a fresh runtime session identity. + + Returns: + A :class:`ResetSessionResult` containing the fresh session and the + previous session ID. + + Raises: + RuntimeError: If this session is no longer attached to its creating client. + """ + if self._reset_callback is None: + raise RuntimeError( + "Cannot reset a session that is not attached to its creating client." + ) + return await self._reset_callback(self, config) async def __aenter__(self) -> CopilotSession: """Enable use as an async context manager.""" @@ -2442,10 +2503,8 @@ async def set_model( """ rpc_caps = None if model_capabilities is not None: - from .client import _capabilities_to_dict - rpc_caps = _RpcModelCapabilitiesOverride.from_dict( - _capabilities_to_dict(model_capabilities) + capabilities_to_dict(model_capabilities) ) await self.rpc.model.switch_to( ModelSwitchToRequest( diff --git a/rust/README.md b/rust/README.md index 0b5bec1cd..b490cb2d4 100644 --- a/rust/README.md +++ b/rust/README.md @@ -525,6 +525,20 @@ config.commands = Some(vec![ Only `name` and `description` are sent over the wire; the handler stays in your process. Returning `Err(_)` surfaces the message back through the TUI. +### Resetting a Session + +Use `session.reset(config).await` to abandon the current runtime session and create a fresh session from explicit configuration. This mirrors the SDK-owned lifecycle part of the CLI TUI `/clear` command; `/reset` is its alias in the TUI. + +```rust,ignore +let result = session.reset(SessionConfig::default() + .with_model("gpt-5") + .with_permission_handler(Arc::new(ApproveAllHandler))).await?; +let session = result.session; +// Clear your app's visible transcript, local drafts, and route state here. +``` + +The returned `previous_session_id` identifies the abandoned session. The old `Session` handle is closed after a successful reset, and the new session starts unnamed. If reset fails after teardown starts, treat the old session as no longer usable and create or resume another session explicitly. Host applications own UI cleanup and event listener rebinding. + ### Streaming Set `streaming: true` to receive incremental delta events alongside finalized messages: diff --git a/rust/src/errors.rs b/rust/src/errors.rs index 5690f6412..5049f45fc 100644 --- a/rust/src/errors.rs +++ b/rust/src/errors.rs @@ -119,6 +119,9 @@ pub enum SessionErrorKind { /// `send` was called while a `send_and_wait` is in flight. SendWhileWaiting, + /// A reset operation is already in progress for this session. + ResetInProgress(SessionId), + /// The session event loop exited before a pending `send_and_wait` completed. EventLoopClosed, @@ -154,6 +157,9 @@ impl fmt::Display for SessionErrorKind { SessionErrorKind::SendWhileWaiting => { write!(f, "cannot send while send_and_wait is in flight") } + SessionErrorKind::ResetInProgress(id) => { + write!(f, "reset already in progress for session {id}") + } SessionErrorKind::EventLoopClosed => { write!(f, "event loop closed before session reached idle") } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cab34b476..6b91868c0 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -55,9 +55,11 @@ pub(crate) mod generated; /// source-qualified tool filter patterns. pub mod mode; +use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Instant; @@ -769,6 +771,9 @@ struct ClientInner { request_rx: parking_lot::Mutex>>, notification_tx: broadcast::Sender, router: router::SessionRouter, + session_registrations: parking_lot::Mutex>, + next_session_registration_id: AtomicU64, + resetting_sessions: parking_lot::Mutex>, negotiated_protocol_version: OnceLock, state: parking_lot::Mutex, lifecycle_tx: broadcast::Sender, @@ -1131,6 +1136,9 @@ impl Client { request_rx: parking_lot::Mutex::new(Some(request_rx)), notification_tx: notification_broadcast_tx, router: router::SessionRouter::new(), + session_registrations: parking_lot::Mutex::new(HashMap::new()), + next_session_registration_id: AtomicU64::new(1), + resetting_sessions: parking_lot::Mutex::new(HashSet::new()), negotiated_protocol_version: OnceLock::new(), state: parking_lot::Mutex::new(ConnectionState::Connected), lifecycle_tx: broadcast::channel(256).0, @@ -1534,18 +1542,52 @@ impl Client { pub(crate) fn register_session( &self, session_id: &SessionId, - ) -> crate::router::SessionChannels { + ) -> (crate::router::SessionChannels, u64) { self.inner .router .ensure_started(&self.inner.notification_tx, &self.inner.request_rx); - self.inner.router.register(session_id) + let registration_id = self + .inner + .next_session_registration_id + .fetch_add(1, Ordering::Relaxed); + let mut registrations = self.inner.session_registrations.lock(); + let channels = self.inner.router.register(session_id); + registrations.insert(session_id.clone(), registration_id); + (channels, registration_id) } - /// Unregister a session, dropping its per-session channels. - pub(crate) fn unregister_session(&self, session_id: &SessionId) { + pub(crate) fn unregister_session_registration( + &self, + session_id: &SessionId, + registration_id: u64, + ) { + let mut registrations = self.inner.session_registrations.lock(); + if registrations.get(session_id) != Some(®istration_id) { + return; + } + registrations.remove(session_id); self.inner.router.unregister(session_id); } + pub(crate) fn is_session_registration_active( + &self, + session_id: &SessionId, + registration_id: u64, + ) -> bool { + self.inner.session_registrations.lock().get(session_id) == Some(®istration_id) + } + + pub(crate) fn begin_session_reset(&self, session_id: &SessionId) -> bool { + self.inner + .resetting_sessions + .lock() + .insert(session_id.clone()) + } + + pub(crate) fn end_session_reset(&self, session_id: &SessionId) { + self.inner.resetting_sessions.lock().remove(session_id); + } + /// Returns the protocol version negotiated with the CLI server, if any. /// /// Set during [`start`](Self::start). Returns `None` if the server didn't @@ -1871,6 +1913,8 @@ impl Client { let child = self.inner.child.lock().take(); *self.inner.state.lock() = ConnectionState::Disconnected; *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); + self.inner.session_registrations.lock().clear(); + self.inner.resetting_sessions.lock().clear(); if let Some(mut child) = child && let Err(e) = child.kill().await { @@ -1926,6 +1970,8 @@ impl Client { // Drop all session channels so any awaiters see a closed channel // instead of waiting for responses that will never arrive. self.inner.router.clear(); + self.inner.session_registrations.lock().clear(); + self.inner.resetting_sessions.lock().clear(); *self.inner.state.lock() = ConnectionState::Disconnected; *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); } @@ -2541,6 +2587,9 @@ mod tests { request_rx: parking_lot::Mutex::new(None), notification_tx: broadcast::channel(16).0, router: router::SessionRouter::new(), + session_registrations: parking_lot::Mutex::new(HashMap::new()), + next_session_registration_id: AtomicU64::new(1), + resetting_sessions: parking_lot::Mutex::new(HashSet::new()), negotiated_protocol_version: OnceLock::new(), state: parking_lot::Mutex::new(ConnectionState::Connected), lifecycle_tx: broadcast::channel(16).0, diff --git a/rust/src/session.rs b/rust/src/session.rs index f387b8627..6119d0520 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -86,15 +86,22 @@ impl Drop for WaiterGuard { struct PendingSessionRegistration { client: Client, session_id: SessionId, + registration_id: u64, shutdown: CancellationToken, disarmed: bool, } impl PendingSessionRegistration { - fn new(client: Client, session_id: SessionId, shutdown: CancellationToken) -> Self { + fn new( + client: Client, + session_id: SessionId, + registration_id: u64, + shutdown: CancellationToken, + ) -> Self { Self { client, session_id, + registration_id, shutdown, disarmed: false, } @@ -103,7 +110,8 @@ impl PendingSessionRegistration { async fn cleanup(mut self, event_loop: JoinHandle<()>) { self.shutdown.cancel(); let _ = event_loop.await; - self.client.unregister_session(&self.session_id); + self.client + .unregister_session_registration(&self.session_id, self.registration_id); self.disarmed = true; } @@ -116,7 +124,8 @@ impl Drop for PendingSessionRegistration { fn drop(&mut self) { if !self.disarmed { self.shutdown.cancel(); - self.client.unregister_session(&self.session_id); + self.client + .unregister_session_registration(&self.session_id, self.registration_id); } } } @@ -135,6 +144,7 @@ impl Drop for PendingSessionRegistration { /// unregisters from the router as a best-effort safety net. pub struct Session { id: SessionId, + registration_id: u64, cwd: PathBuf, workspace_path: Option, remote_url: Option, @@ -173,6 +183,14 @@ pub struct Session { event_tx: tokio::sync::broadcast::Sender, } +/// Result returned by [`Session::reset`]. +pub struct ResetSessionResult { + /// Session ID that was closed and replaced. + pub previous_session_id: SessionId, + /// Fresh session created from the supplied reset configuration. + pub session: Session, +} + impl Session { /// Session ID assigned by the CLI. pub fn id(&self) -> &SessionId { @@ -555,10 +573,53 @@ impl Session { ) .await?; self.stop_event_loop().await; - self.client.unregister_session(&self.id); + self.client + .unregister_session_registration(&self.id, self.registration_id); Ok(()) } + /// Reset this conversation by closing the underlying runtime session and + /// creating a fresh session from `config`. + /// + /// Use the returned session for subsequent work. The SDK does not clear + /// host-owned UI state, local drafts, or app persistence. If reset fails + /// after the old session has been torn down, treat this session handle as + /// inactive and create or resume another session explicitly. + pub async fn reset(&self, mut config: SessionConfig) -> Result { + let previous_session_id = self.id.clone(); + if !self + .client + .is_session_registration_active(&previous_session_id, self.registration_id) + { + return Err(Error::with_message( + ErrorKind::Session(SessionErrorKind::NotFound(previous_session_id)), + "cannot reset session: it is not active on this client", + )); + } + if !self.client.begin_session_reset(&previous_session_id) { + return Err( + ErrorKind::Session(SessionErrorKind::ResetInProgress(previous_session_id)).into(), + ); + } + + let result = async { + self.rpc().queue().clear().await?; + self.disconnect().await?; + + config.session_id = None; + let session = self.client.create_session(config).await?; + + Ok(ResetSessionResult { + previous_session_id: previous_session_id.clone(), + session, + }) + } + .await; + + self.client.end_session_reset(&previous_session_id); + result + } + /// Deprecated alias for [`disconnect`](Self::disconnect). The /// underlying wire RPC happens to be named `session.destroy`, but it /// only severs the connection — on-disk session state is preserved. @@ -632,7 +693,8 @@ impl Drop for Session { // tokio runtime when it next polls; we intentionally don't await // it here because Drop is sync. self.shutdown.cancel(); - self.client.unregister_session(&self.id); + self.client + .unregister_session_registration(&self.id, self.registration_id); } } @@ -923,14 +985,14 @@ impl Client { // For non-cloud sessions, register up-front so the CLI can issue // session-scoped requests during session.create processing. let inline_stash: Arc< - ParkingLotMutex>, + ParkingLotMutex>, > = Arc::new(ParkingLotMutex::new(None)); let inline_callback: Option = if let Some(ref sid) = local_session_id { - let channels = self.register_session(sid); - *inline_stash.lock() = Some((sid.clone(), channels)); + let (channels, registration_id) = self.register_session(sid); + *inline_stash.lock() = Some((sid.clone(), channels, registration_id)); None } else { let client = self.clone(); @@ -951,8 +1013,8 @@ impl Client { }) .into()); } - let channels = client.register_session(&parsed.session_id); - *stash.lock() = Some((parsed.session_id, channels)); + let (channels, registration_id) = client.register_session(&parsed.session_id); + *stash.lock() = Some((parsed.session_id, channels, registration_id)); Ok(()) })) }; @@ -964,8 +1026,8 @@ impl Client { { Ok(result) => result, Err(error) => { - if let Some((id, _channels)) = inline_stash.lock().take() { - self.unregister_session(&id); + if let Some((id, _channels, registration_id)) = inline_stash.lock().take() { + self.unregister_session_registration(&id, registration_id); } return Err(error); } @@ -977,8 +1039,8 @@ impl Client { let create_result: CreateSessionResult = match serde_json::from_value(result) { Ok(result) => result, Err(error) => { - if let Some((id, _channels)) = inline_stash.lock().take() { - self.unregister_session(&id); + if let Some((id, _channels, registration_id)) = inline_stash.lock().take() { + self.unregister_session_registration(&id, registration_id); } return Err(error.into()); } @@ -987,8 +1049,8 @@ impl Client { if let Some(ref requested) = local_session_id && create_result.session_id != *requested { - if let Some((id, _channels)) = inline_stash.lock().take() { - self.unregister_session(&id); + if let Some((id, _channels, registration_id)) = inline_stash.lock().take() { + self.unregister_session_registration(&id, registration_id); } return Err(ErrorKind::Session(SessionErrorKind::SessionIdMismatch { requested: requested.clone(), @@ -997,7 +1059,7 @@ impl Client { .into()); } - let (session_id, channels) = inline_stash + let (session_id, channels, registration_id) = inline_stash .lock() .take() .expect("session registration must have populated stash on success"); @@ -1034,6 +1096,7 @@ impl Client { ); let session = Session { id: session_id, + registration_id, cwd: self.cwd().clone(), workspace_path: create_result.workspace_path, remote_url: create_result.remote_url, @@ -1167,7 +1230,7 @@ impl Client { let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default())); let setup_start = Instant::now(); - let channels = self.register_session(&session_id); + let (channels, registration_id) = self.register_session(&session_id); let idle_waiter = Arc::new(ParkingLotMutex::new(None)); let open_canvases = Arc::new(parking_lot::RwLock::new(Vec::new())); let shutdown = CancellationToken::new(); @@ -1188,8 +1251,12 @@ impl Client { event_tx.clone(), shutdown.clone(), ); - let mut registration = - PendingSessionRegistration::new(self.clone(), session_id.clone(), shutdown.clone()); + let mut registration = PendingSessionRegistration::new( + self.clone(), + session_id.clone(), + registration_id, + shutdown.clone(), + ); tracing::debug!( elapsed_ms = setup_start.elapsed().as_millis(), session_id = %session_id, @@ -1276,6 +1343,7 @@ impl Client { registration.disarm(); let session = Session { id: session_id, + registration_id, cwd: self.cwd().clone(), workspace_path: resume_result.workspace_path, remote_url: resume_result.remote_url, @@ -2318,10 +2386,17 @@ fn inject_transform_sections_resume( #[cfg(test)] mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use parking_lot::Mutex as ParkingLotMutex; use serde_json::json; + use tokio_util::sync::CancellationToken; - use super::notification_permission_payload; + use super::{Session, notification_permission_payload}; use crate::handler::PermissionResult; + use crate::types::{SessionConfig, SessionId}; + use crate::{Client, ErrorKind, SessionErrorKind}; #[test] fn notification_payload_suppresses_no_result() { @@ -2347,4 +2422,43 @@ mod tests { Some(json!({ "kind": "user-not-available" })) ); } + + #[tokio::test] + async fn reset_rejects_inactive_session_handle() { + let (client_read, _server_write) = tokio::io::duplex(1024); + let (_server_read, client_write) = tokio::io::duplex(1024); + let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap(); + let session_id = SessionId::new("inactive-reset-test"); + let (_channels, registration_id) = client.register_session(&session_id); + client.unregister_session_registration(&session_id, registration_id); + + let session = Session { + id: session_id.clone(), + registration_id, + cwd: PathBuf::from("."), + workspace_path: None, + remote_url: None, + client, + event_loop: ParkingLotMutex::new(None), + shutdown: CancellationToken::new(), + idle_waiter: Arc::new(ParkingLotMutex::new(None)), + capabilities: Arc::new(parking_lot::RwLock::new(Default::default())), + open_canvases: Arc::new(parking_lot::RwLock::new(Vec::new())), + event_tx: tokio::sync::broadcast::channel(1).0, + }; + + let error = match session.reset(SessionConfig::default()).await { + Ok(_) => panic!("inactive reset should fail before sending RPC"), + Err(error) => error, + }; + + assert_eq!( + error.kind(), + &ErrorKind::Session(SessionErrorKind::NotFound(session_id)) + ); + assert_eq!( + error.message(), + Some("cannot reset session: it is not active on this client") + ); + } }