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