/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.ExternalToolRequestedEvent; import com.github.copilot.sdk.events.PermissionRequestedEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.json.AgentInfo; import com.github.copilot.sdk.json.GetMessagesResponse; import com.github.copilot.sdk.json.HookInvocation; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PermissionInvocation; import com.github.copilot.sdk.json.PermissionRequest; import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.PostToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.SendMessageRequest; import com.github.copilot.sdk.json.SendMessageResponse; import com.github.copilot.sdk.json.SessionEndHookInput; import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; import com.github.copilot.sdk.json.ToolDefinition; import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; import com.github.copilot.sdk.json.UserInputInvocation; import com.github.copilot.sdk.json.UserInputRequest; import com.github.copilot.sdk.json.UserInputResponse; import com.github.copilot.sdk.json.UserPromptSubmittedHookInput; /** * Represents a single conversation session with the Copilot CLI. *
* A session maintains conversation state, handles events, and manages tool * execution. Sessions are created via {@link CopilotClient#createSession} or * resumed via {@link CopilotClient#resumeSession}. *
* {@code CopilotSession} implements {@link AutoCloseable}. Use the * try-with-resources pattern for automatic cleanup, or call {@link #close()} * explicitly. Closing a session releases in-memory resources but preserves * session data on disk — the conversation can be resumed later via * {@link CopilotClient#resumeSession}. To permanently delete session data, use * {@link CopilotClient#deleteSession}. * *
{@code
* // Create a session with a permission handler (required)
* var session = client
* .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5"))
* .get();
*
* // Register type-safe event handlers
* session.on(AssistantMessageEvent.class, msg -> {
* System.out.println(msg.getData().content());
* });
* session.on(SessionIdleEvent.class, idle -> {
* System.out.println("Session is idle");
* });
*
* // Send messages
* session.sendAndWait(new MessageOptions().setPrompt("Hello!")).get();
*
* // Clean up
* session.close();
* }
*
* @see CopilotClient#createSession(com.github.copilot.sdk.json.SessionConfig)
* @see CopilotClient#resumeSession(String,
* com.github.copilot.sdk.json.ResumeSessionConfig)
* @see AbstractSessionEvent
* @since 1.0.0
*/
public final class CopilotSession implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(CopilotSession.class.getName());
private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper();
/**
* The current active session ID. Initialized to the pre-generated value and may
* be updated after session.create / session.resume if the server returns a
* different ID (e.g. when working against a v2 CLI that ignores the
* client-supplied sessionId).
*/
private volatile String sessionId;
private volatile String workspacePath;
private final JsonRpcClient rpc;
private final Set* This constructor is package-private. Sessions should be created via * {@link CopilotClient#createSession} or {@link CopilotClient#resumeSession}. * * @param sessionId * the unique session identifier * @param rpc * the JSON-RPC client for communication */ CopilotSession(String sessionId, JsonRpcClient rpc) { this(sessionId, rpc, null); } /** * Creates a new session with the given ID, RPC client, and workspace path. *
* This constructor is package-private. Sessions should be created via * {@link CopilotClient#createSession} or {@link CopilotClient#resumeSession}. * * @param sessionId * the unique session identifier * @param rpc * the JSON-RPC client for communication * @param workspacePath * the workspace path if infinite sessions are enabled */ CopilotSession(String sessionId, JsonRpcClient rpc, String workspacePath) { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; var executor = new ScheduledThreadPoolExecutor(1, r -> { var t = new Thread(r, "sendAndWait-timeout"); t.setDaemon(true); return t; }); executor.setRemoveOnCancelPolicy(true); this.timeoutScheduler = executor; } /** * Gets the unique identifier for this session. * * @return the session ID */ public String getSessionId() { return sessionId; } /** * Updates the active session ID. Package-private; called by CopilotClient if * the server returns a different session ID than the pre-generated one (e.g. * when a v2 CLI ignores the client-supplied sessionId). * * @param sessionId * the server-confirmed session ID */ void setActiveSessionId(String sessionId) { this.sessionId = sessionId; } /** * Gets the path to the session workspace directory when infinite sessions are * enabled. *
* The workspace directory contains checkpoints/, plan.md, and files/ * subdirectories. * * @return the workspace path, or {@code null} if infinite sessions are disabled */ public String getWorkspacePath() { return workspacePath; } /** * Sets the workspace path. Package-private; called by CopilotClient after * session.create or session.resume RPC response. * * @param workspacePath * the workspace path */ void setWorkspacePath(String workspacePath) { this.workspacePath = workspacePath; } /** * Sets a custom error handler for exceptions thrown by event handlers. *
* When an event handler registered via {@link #on(Consumer)} or * {@link #on(Class, Consumer)} throws an exception during event dispatch, the * error handler is invoked with the event and exception. The error is always * logged at {@link Level#WARNING} regardless of whether a custom handler is * set. * *
* Whether dispatch continues or stops after an error is controlled by the * {@link EventErrorPolicy} set via {@link #setEventErrorPolicy}. The error * handler is always invoked regardless of the policy. * *
* If the error handler itself throws an exception, that exception is caught and * logged at {@link Level#SEVERE}, and dispatch is stopped regardless of the * configured policy. * *
* Example: * *
{@code
* session.setEventErrorHandler((event, exception) -> {
* metrics.increment("handler.errors");
* logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
* });
* }
*
* @param handler
* the error handler, or {@code null} to use only the default logging
* behavior
* @throws IllegalStateException
* if this session has been terminated
* @see EventErrorHandler
* @see #setEventErrorPolicy(EventErrorPolicy)
* @since 1.0.8
*/
public void setEventErrorHandler(EventErrorHandler handler) {
ensureNotTerminated();
this.eventErrorHandler = handler;
}
/**
* Sets the error propagation policy for event dispatch.
* * Controls whether remaining event listeners continue to execute when a * preceding listener throws an exception. Errors are always logged at * {@link Level#WARNING} regardless of the policy. * *
* The configured {@link EventErrorHandler} (if any) is always invoked * regardless of the policy. * *
* Example: * *
{@code
* // Opt-in to suppress errors (continue dispatching despite errors)
* session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS);
* session.setEventErrorHandler((event, ex) -> logger.error("Handler failed, continuing: {}", ex.getMessage(), ex));
* }
*
* @param policy
* the error policy (default is
* {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS})
* @throws IllegalStateException
* if this session has been terminated
* @see EventErrorPolicy
* @see #setEventErrorHandler(EventErrorHandler)
* @since 1.0.8
*/
public void setEventErrorPolicy(EventErrorPolicy policy) {
ensureNotTerminated();
if (policy == null) {
throw new NullPointerException("policy must not be null");
}
this.eventErrorPolicy = policy;
}
/**
* Sends a simple text message to the Copilot session.
*
* This is a convenience method equivalent to
* {@code send(new MessageOptions().setPrompt(prompt))}.
*
* @param prompt
* the message text to send
* @return a future that resolves with the message ID assigned by the server
* @throws IllegalStateException
* if this session has been terminated
* @see #send(MessageOptions)
*/
public CompletableFuture
* This is a convenience method equivalent to
* {@code sendAndWait(new MessageOptions().setPrompt(prompt))}.
*
* @param prompt
* the message text to send
* @return a future that resolves with the final assistant message event, or
* {@code null} if no assistant message was received
* @throws IllegalStateException
* if this session has been terminated
* @see #sendAndWait(MessageOptions)
*/
public CompletableFuture
* This method sends a message asynchronously and returns immediately. Use
* {@link #sendAndWait(MessageOptions)} to wait for the response.
*
* @param options
* the message options containing the prompt and attachments
* @return a future that resolves with the message ID assigned by the server
* @throws IllegalStateException
* if this session has been terminated
* @see #sendAndWait(MessageOptions)
* @see #send(String)
*/
public CompletableFuture
* This method blocks until the assistant finishes processing the message or
* until the timeout expires. It's suitable for simple request/response
* interactions where you don't need to process streaming events.
*
* The returned future can be cancelled via
* {@link java.util.concurrent.Future#cancel(boolean)}. If cancelled externally,
* the future completes with {@link java.util.concurrent.CancellationException}.
* If the timeout expires first, the future completes exceptionally with a
* {@link TimeoutException}.
*
* @param options
* the message options containing the prompt and attachments
* @param timeoutMs
* timeout in milliseconds (0 or negative for no timeout)
* @return a future that resolves with the final assistant message event, or
* {@code null} if no assistant message was received. The future
* completes exceptionally with a TimeoutException if the timeout
* expires, or with CancellationException if cancelled externally.
* @throws IllegalStateException
* if this session has been terminated
* @see #sendAndWait(MessageOptions)
* @see #send(MessageOptions)
*/
public CompletableFuture
* The handler will be invoked for every event in this session, including
* assistant messages, tool calls, and session state changes. For type-safe
* handling of specific event types, prefer {@link #on(Class, Consumer)}
* instead.
*
*
* Exception handling: If a handler throws an exception, the error is
* routed to the configured {@link EventErrorHandler} (if set). Whether
* remaining handlers execute depends on the configured
* {@link EventErrorPolicy}.
*
*
* Example:
*
*
* This provides a type-safe way to handle specific events without needing
* {@code instanceof} checks. The handler will only be called for events
* matching the specified type.
*
*
* Exception handling: If a handler throws an exception, the error is
* routed to the configured {@link EventErrorHandler} (if set). Whether
* remaining handlers execute depends on the configured
* {@link EventErrorPolicy}.
*
*
* Example Usage
*
* This is called internally when events are received from the server. Each
* handler is invoked in its own try/catch block. Errors are always logged at
* {@link Level#WARNING}. Whether dispatch continues after a handler error
* depends on the configured {@link EventErrorPolicy}:
*
* The configured {@link EventErrorHandler} is always invoked (if set),
* regardless of the policy. If the error handler itself throws, dispatch stops
* regardless of policy and the error is logged at {@link Level#SEVERE}.
*
* @param event
* the event to dispatch
* @see #setEventErrorHandler(EventErrorHandler)
* @see #setEventErrorPolicy(EventErrorPolicy)
*/
void dispatchEvent(AbstractSessionEvent event) {
// Handle broadcast request events (protocol v3) before dispatching to user
// handlers. These are fire-and-forget: the response is sent asynchronously.
handleBroadcastEventAsync(event);
for (Consumer
* Fire-and-forget: the response is sent asynchronously.
*
* @param event
* the event to handle
*/
private void handleBroadcastEventAsync(AbstractSessionEvent event) {
if (event instanceof ExternalToolRequestedEvent toolEvent) {
var data = toolEvent.getData();
if (data == null || data.requestId() == null || data.toolName() == null) {
return;
}
ToolDefinition tool = getTool(data.toolName());
if (tool == null) {
return; // This client doesn't handle this tool; another client will
}
executeToolAndRespondAsync(data.requestId(), data.toolName(), data.toolCallId(), data.arguments(), tool);
} else if (event instanceof PermissionRequestedEvent permEvent) {
var data = permEvent.getData();
if (data == null || data.requestId() == null || data.permissionRequest() == null) {
return;
}
PermissionHandler handler = permissionHandler.get();
if (handler == null) {
return; // This client doesn't handle permissions; another client will
}
executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler);
}
}
/**
* Executes a tool handler and sends the result back via
* {@code session.tools.handlePendingToolCall}.
*/
private void executeToolAndRespondAsync(String requestId, String toolName, String toolCallId, Object arguments,
ToolDefinition tool) {
CompletableFuture.runAsync(() -> {
try {
JsonNode argumentsNode = arguments instanceof JsonNode jn
? jn
: (arguments != null ? MAPPER.valueToTree(arguments) : null);
var invocation = new com.github.copilot.sdk.json.ToolInvocation().setSessionId(sessionId)
.setToolCallId(toolCallId).setToolName(toolName).setArguments(argumentsNode);
tool.handler().invoke(invocation).thenAccept(result -> {
try {
ToolResultObject toolResult;
if (result instanceof ToolResultObject tr) {
toolResult = tr;
} else {
toolResult = ToolResultObject
.success(result instanceof String s ? s : MAPPER.writeValueAsString(result));
}
rpc.invoke("session.tools.handlePendingToolCall",
Map.of("sessionId", sessionId, "requestId", requestId, "result", toolResult),
Object.class);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error sending tool result for requestId=" + requestId, e);
}
}).exceptionally(ex -> {
try {
rpc.invoke(
"session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId",
requestId, "error", ex.getMessage() != null ? ex.getMessage() : ex.toString()),
Object.class);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, e);
}
return null;
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error executing tool for requestId=" + requestId, e);
try {
rpc.invoke(
"session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId",
requestId, "error", e.getMessage() != null ? e.getMessage() : e.toString()),
Object.class);
} catch (Exception sendEx) {
LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, sendEx);
}
}
});
}
/**
* Executes a permission handler and sends the result back via
* {@code session.permissions.handlePendingPermissionRequest}.
*/
private void executePermissionAndRespondAsync(String requestId, PermissionRequest permissionRequest,
PermissionHandler handler) {
CompletableFuture.runAsync(() -> {
try {
var invocation = new PermissionInvocation();
invocation.setSessionId(sessionId);
handler.handle(permissionRequest, invocation).thenAccept(result -> {
try {
PermissionRequestResultKind kind = new PermissionRequestResultKind(result.getKind());
if (PermissionRequestResultKind.NO_RESULT.equals(kind)) {
// Handler explicitly abstains — leave the request unanswered
// so another client can handle it.
return;
}
rpc.invoke("session.permissions.handlePendingPermissionRequest",
Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error sending permission result for requestId=" + requestId, e);
}
}).exceptionally(ex -> {
try {
PermissionRequestResult denied = new PermissionRequestResult();
denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER);
rpc.invoke("session.permissions.handlePendingPermissionRequest",
Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, e);
}
return null;
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error executing permission handler for requestId=" + requestId, e);
try {
PermissionRequestResult denied = new PermissionRequestResult();
denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER);
rpc.invoke("session.permissions.handlePendingPermissionRequest",
Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class);
} catch (Exception sendEx) {
LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, sendEx);
}
}
});
}
/**
* Registers custom tool handlers for this session.
*
* Called internally when creating or resuming a session with tools.
*
* @param tools
* the list of tool definitions with handlers
*/
void registerTools(List
* Called internally when creating or resuming a session with permission
* handling.
*
* @param handler
* the permission handler
*/
void registerPermissionHandler(PermissionHandler handler) {
permissionHandler.set(handler);
}
/**
* Handles a permission request from the Copilot CLI.
*
* Called internally when the server requests permission for an operation.
*
* @param permissionRequestData
* the JSON data for the permission request
* @return a future that resolves with the permission result
*/
CompletableFuture
* Called internally when creating or resuming a session with user input
* handling.
*
* @param handler
* the user input handler
*/
void registerUserInputHandler(UserInputHandler handler) {
userInputHandler.set(handler);
}
/**
* Handles a user input request from the Copilot CLI.
*
* Called internally when the server requests user input.
*
* @param request
* the user input request
* @return a future that resolves with the user input response
*/
CompletableFuture
* Called internally when creating or resuming a session with hooks.
*
* @param hooks
* the hooks configuration
*/
void registerHooks(SessionHooks hooks) {
hooksHandler.set(hooks);
}
/**
* Registers transform callbacks for system message sections.
*
* Called internally when creating or resuming a session with
* {@link com.github.copilot.sdk.SystemMessageMode#CUSTOMIZE} and transform
* callbacks.
*
* @param callbacks
* the transform callbacks keyed by section identifier; {@code null}
* clears any previously registered callbacks
*/
void registerTransformCallbacks(
Map
* The CLI sends section content; the SDK invokes the registered transform
* callbacks and returns the transformed sections.
*
* @param sections
* JSON node containing sections keyed by section identifier
* @return a future resolving with a map of transformed sections
*/
CompletableFuture{@code
* // Collect all events
* var events = new ArrayList
*
* @param handler
* a callback to be invoked when a session event occurs
* @return a Closeable that, when closed, unsubscribes the handler
* @throws IllegalStateException
* if this session has been terminated
* @see #on(Class, Consumer)
* @see AbstractSessionEvent
* @see #setEventErrorPolicy(EventErrorPolicy)
*/
public Closeable on(Consumer{@code
* // Handle assistant messages
* session.on(AssistantMessageEvent.class, msg -> {
* System.out.println(msg.getData().content());
* });
*
* // Handle session idle
* session.on(SessionIdleEvent.class, idle -> {
* done.complete(null);
* });
*
* // Handle streaming deltas
* session.on(AssistantMessageDeltaEvent.class, delta -> {
* System.out.print(delta.getData().deltaContent());
* });
* }
*
* @param
*
*