diff --git a/CHANGELOG.md b/CHANGELOG.md index d695ed6fb..f530d4565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: selectable `ask_user` session behavior + +Session create and cold resume now accept a language-specific `askUserVariant` option with `legacy` and `elicitation` values. SDK sessions retain the legacy question-and-answer tool by default. Select `elicitation` and provide an elicitation handler to expose the structured form-based `ask_user` tool. + ### Feature: rotating session-scoped GitHub credentials All six SDKs can now acquire short-lived GitHub credentials through a session-scoped callback. The SDK registers the callback before session create or resume, maps `initial` and `refresh` requests to the owning session, and removes registrations on rollback, replacement, session close, and client close. Static per-session `gitHubToken` credentials remain supported and are mutually exclusive with the callback. diff --git a/dotnet/README.md b/dotnet/README.md index 461ff0cf9..a6a419644 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -135,7 +135,8 @@ Create a new conversation session. - `EnableSessionStore` - Enables the cross-session store for search and retrieval across sessions. When unset in `CopilotClientMode.CopilotCli`, the runtime default applies (enabled). In `CopilotClientMode.Empty`, defaults to disabled. - `GitHubTokenProvider` - Acquires session-scoped GitHub tokens on demand. Return `GitHubTokenProviderResult.FromToken` with a positive `ExpiresIn` value (production GitHub tokens typically use `8 * 60 * 60` seconds), or `GitHubTokenProviderResult.Cancel()`. Cannot be combined with `GitHubToken`. - `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves requests when managed settings are disabled and throws when `EnableManagedSettings` is true. Custom handlers can inspect `ManagedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `OnUserInputRequest` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `AskUserVariant` - Selects the model-facing `ask_user` tool shape. Defaults to `AskUserVariant.Legacy`; use `AskUserVariant.Elicitation` with `OnElicitationRequest`. - `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ##### `ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null): Task` @@ -146,6 +147,7 @@ Resume an existing session. Returns the session with `WorkspacePath` populated i - `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. See [Permission Handling](#permission-handling) section. - `GitHubTokenProvider` - Replaces the session-scoped token provider when resuming. Cannot be combined with `GitHubToken`. +- `AskUserVariant` - Re-supplies the model-facing `ask_user` tool shape on cold resume. ```csharp await using var session = await client.CreateSessionAsync(new SessionConfig @@ -871,7 +873,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `SkipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `OnUserInputRequest` handler: ```csharp var session = await client.CreateSessionAsync(new SessionConfig @@ -1004,6 +1006,7 @@ var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", OnPermissionRequest = PermissionHandler.ApproveAll, + AskUserVariant = AskUserVariant.Elicitation, OnElicitationRequest = async (context) => { // context.SessionId - Session that triggered the request diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f2da0a48f..157e51981 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1190,6 +1190,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.EnableCitations, config.EnableFileChangeTracking, wireSystemMessage, + config.AskUserVariant, toolFilter.AvailableTools, toolFilter.ExcludedTools, config.ExcludedBuiltInAgents, @@ -1428,6 +1429,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.EnableCitations, config.EnableFileChangeTracking, wireSystemMessage, + config.AskUserVariant, toolFilter.AvailableTools, toolFilter.ExcludedTools, config.ExcludedBuiltInAgents, @@ -2868,6 +2870,7 @@ internal record CreateSessionRequest( bool? EnableCitations, bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, + AskUserVariant? AskUserVariant, IList? AvailableTools, IList? ExcludedTools, [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, @@ -2983,6 +2986,7 @@ internal record ResumeSessionRequest( bool? EnableCitations, bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, + AskUserVariant? AskUserVariant, IList? AvailableTools, IList? ExcludedTools, [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6ed05e306..6cf02d2a7 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3116,6 +3116,21 @@ public sealed class ManagedSettings public ManagedSettingsPermissions? Permissions { get; set; } } +/// +/// Selects the model-facing shape of the built-in ask_user tool. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AskUserVariant +{ + /// Use the legacy user-input request flow. + [JsonStringEnumMemberName("legacy")] + Legacy, + + /// Use the elicitation request flow. + [JsonStringEnumMemberName("elicitation")] + Elicitation +} + /// /// Shared configuration properties for creating or resuming a Copilot session. /// Use when creating a new session, or @@ -3205,6 +3220,7 @@ protected SessionConfigBase(SessionConfigBase? other) ReasoningEffort = other.ReasoningEffort; ReasoningSummary = other.ReasoningSummary; ContextTier = other.ContextTier; + AskUserVariant = other.AskUserVariant; CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; GitHubTokenProvider = other.GitHubTokenProvider; @@ -3372,6 +3388,15 @@ protected SessionConfigBase(SessionConfigBase? other) /// System message configuration for the session. public SystemMessageConfig? SystemMessage { get; set; } + /// + /// Selects the model-facing shape of the built-in ask_user tool. + /// The default is . To use + /// , also provide + /// so the host can answer structured forms. + /// The runtime resolves this option when it creates or cold-resumes the session. + /// + public AskUserVariant? AskUserVariant { get; set; } + /// List of tool names to allow; only these tools will be available when specified. public IList? AvailableTools { get; set; } @@ -3470,7 +3495,11 @@ protected SessionConfigBase(SessionConfigBase? other) /// Handler for permission requests from the server. public Func>? OnPermissionRequest { get; set; } - /// Handler for user input requests from the agent. + /// + /// Handler for user input requests from the agent. When provided with the default + /// variant, enables the + /// question-and-answer form of the ask_user tool. + /// public Func>? OnUserInputRequest { get; set; } /// Slash commands registered for this session. diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index b61546c65..bb0042efd 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -464,6 +464,54 @@ public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unse Assert.False(agent.TryGetProperty("reasoningEffort", out _)); } + [Fact] + public async Task CreateSessionAsync_Forwards_AskUserVariant() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var session = await client.CreateSessionAsync(new SessionConfig + { + AskUserVariant = AskUserVariant.Elicitation, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.create"); + Assert.Equal("elicitation", request.Params.GetProperty("askUserVariant").GetString()); + + server.ClearRequests(); + await using var defaultSession = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); + Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); + } + + [Fact] + public async Task ResumeSessionAsync_Forwards_AskUserVariant_On_Cold_Resume() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var session = await client.ResumeSessionAsync("ask-user-variant", new ResumeSessionConfig + { + AskUserVariant = AskUserVariant.Legacy, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.resume"); + Assert.Equal("legacy", request.Params.GetProperty("askUserVariant").GetString()); + + server.ClearRequests(); + await using var defaultSession = await client.ResumeSessionAsync("ask-user-variant-default", new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); + Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); + } + [Fact] public async Task SessionRequests_Serialize_AdditionalDirectories() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 4bacdfe33..2948ac212 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -73,6 +73,7 @@ public void SessionConfig_Clone_CopiesAllProperties() ReasoningEffort = "high", ReasoningSummary = ReasoningSummary.Detailed, ContextTier = ContextTier.LongContext, + AskUserVariant = AskUserVariant.Elicitation, ConfigDirectory = "/config", AvailableTools = ["tool1", "tool2"], ExcludedTools = ["tool3"], @@ -121,6 +122,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.ReasoningEffort, clone.ReasoningEffort); Assert.Equal(original.ReasoningSummary, clone.ReasoningSummary); Assert.Equal(original.ContextTier, clone.ContextTier); + Assert.Equal(original.AskUserVariant, clone.AskUserVariant); Assert.Equal(original.ConfigDirectory, clone.ConfigDirectory); Assert.Equal(original.AvailableTools, clone.AvailableTools); Assert.Equal(original.ExcludedTools, clone.ExcludedTools); diff --git a/go/README.md b/go/README.md index ddd74b91a..3d8706cec 100644 --- a/go/README.md +++ b/go/README.md @@ -224,7 +224,8 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `EnableSessionStore` (\*bool): Enables the cross-session store for search and retrieval across sessions. When unset in `ModeCopilotCli`, the runtime default applies (enabled). In `ModeEmpty`, defaults to disabled. - `GitHubTokenProvider` (GitHubTokenProvider): Acquires session-scoped GitHub tokens on demand. Return `GitHubTokenResult` with a positive `ExpiresIn` value (production GitHub tokens typically use `8 * 60 * 60` seconds), or `GitHubTokenCancelled`. Cannot be combined with `GitHubToken`. - `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves requests when managed settings are disabled and returns an error when `EnableManagedSettings` is true. Custom handlers can inspect `RequiresManagedApproval()` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `OnUserInputRequest` (UserInputHandler): Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `AskUserVariant` (AskUserVariant): Selects the model-facing shape of the `ask_user` tool. The zero value preserves legacy behavior; use `AskUserVariantElicitation` with `OnElicitationRequest`. - `Hooks` (\*SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. - `Commands` ([]CommandDefinition): Slash-commands registered for this session. See [Commands](#commands) section. - `OnElicitationRequest` (ElicitationHandler): Handler for elicitation requests from the server. See [Elicitation Requests](#elicitation-requests-serverclient) section. @@ -238,6 +239,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `Commands` ([]CommandDefinition): Slash-commands. See [Commands](#commands) section. - `OnElicitationRequest` (ElicitationHandler): Elicitation handler. See [Elicitation Requests](#elicitation-requests-serverclient) section. +- `AskUserVariant` (AskUserVariant): Selects the model-facing shape of the `ask_user` tool on cold resume. Re-supply `AskUserVariantElicitation` with `OnElicitationRequest`; the zero value preserves legacy behavior. - `GitHubTokenProvider` (GitHubTokenProvider): Replaces the session-scoped token provider when resuming. Cannot be combined with `GitHubToken`. ```go @@ -770,7 +772,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `SkipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `OnUserInputRequest` handler: ```go session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ diff --git a/go/client.go b/go/client.go index 4e44696a5..2dbd7f7e9 100644 --- a/go/client.go +++ b/go/client.go @@ -788,6 +788,13 @@ func hasManagedSettings(enableManagedSettings *bool, managedSettings *ManagedSet return (enableManagedSettings != nil && *enableManagedSettings) || managedSettings != nil } +func validateAskUserVariant(variant AskUserVariant) error { + if variant != "" && variant != AskUserVariantLegacy && variant != AskUserVariantElicitation { + return fmt.Errorf("invalid AskUserVariant %q: expected %q, %q, or unset", variant, AskUserVariantLegacy, AskUserVariantElicitation) + } + return nil +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil { config = &SessionConfig{} @@ -795,6 +802,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.GitHubToken != "" && config.GitHubTokenProvider != nil { return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together") } + if err := validateAskUserVariant(config.AskUserVariant); err != nil { + return nil, err + } if err := c.ensureConnected(ctx); err != nil { return nil, err @@ -842,6 +852,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Capi = config.Capi req.Providers = config.Providers req.Models = config.Models + req.AskUserVariant = config.AskUserVariant req.EnableSessionTelemetry = config.EnableSessionTelemetry req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking @@ -1170,6 +1181,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.GitHubToken != "" && config.GitHubTokenProvider != nil { return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together") } + if err := validateAskUserVariant(config.AskUserVariant); err != nil { + return nil, err + } if err := c.ensureConnected(ctx); err != nil { return nil, err @@ -1200,6 +1214,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Capi = config.Capi req.Providers = config.Providers req.Models = config.Models + req.AskUserVariant = config.AskUserVariant req.EnableSessionTelemetry = config.EnableSessionTelemetry req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions diff --git a/go/client_test.go b/go/client_test.go index c6ab0808c..b701de437 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -445,6 +445,85 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { assertCapiEnableWebSocketResponses(t, <-resumeParams) } +func TestClient_ForwardsAskUserVariantToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 2) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + resumeParams := make(chan json.RawMessage, 2) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{ + SessionID: "ask-user-create", + AskUserVariant: AskUserVariantElicitation, + }); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + if _, err := client.ResumeSession(t.Context(), "ask-user-cold-resume", &ResumeSessionConfig{ + AskUserVariant: AskUserVariantLegacy, + }); err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + if _, err := client.CreateSession(t.Context(), &SessionConfig{SessionID: "ask-user-default-create"}); err != nil { + t.Fatalf("CreateSession with default failed: %v", err) + } + if _, err := client.ResumeSession(t.Context(), "ask-user-default-cold-resume", nil); err != nil { + t.Fatalf("ResumeSession with default failed: %v", err) + } + + assertAskUserVariant(t, <-createParams, "elicitation") + assertAskUserVariant(t, <-resumeParams, "legacy") + assertAskUserVariant(t, <-createParams, "") + assertAskUserVariant(t, <-resumeParams, "") +} + +func TestClient_RejectsInvalidAskUserVariant(t *testing.T) { + client := &Client{} + + if _, err := client.CreateSession(t.Context(), &SessionConfig{ + AskUserVariant: AskUserVariant("unknown"), + }); err == nil || !strings.Contains(err.Error(), "AskUserVariant") { + t.Fatalf("CreateSession error = %v, want invalid AskUserVariant error", err) + } + if _, err := client.ResumeSession(t.Context(), "cold-resume", &ResumeSessionConfig{ + AskUserVariant: AskUserVariant("unknown"), + }); err == nil || !strings.Contains(err.Error(), "AskUserVariant") { + t.Fatalf("ResumeSession error = %v, want invalid AskUserVariant error", err) + } +} + +func assertAskUserVariant(t *testing.T, params json.RawMessage, want string) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(params, &payload); err != nil { + t.Fatalf("failed to decode request params: %v", err) + } + got, present := payload["askUserVariant"] + if want == "" { + if present { + t.Fatalf("askUserVariant = %v, want omitted", got) + } + return + } + if got != want { + t.Fatalf("askUserVariant = %v, want %q", got, want) + } +} + func TestClient_ForwardsAdditionalDirectoriesToSessionRequests(t *testing.T) { rpcClient, server, _ := newRuntimeShutdownRpcPair(t) t.Cleanup(server.Stop) diff --git a/go/types.go b/go/types.go index 1d98e0615..a574168ab 100644 --- a/go/types.go +++ b/go/types.go @@ -1251,6 +1251,16 @@ type GitHubMCPToolConfig struct { DisableFormDeferral *bool `json:"disableFormDeferral,omitempty"` } +// AskUserVariant selects the model-facing shape of the ask_user tool. +type AskUserVariant string + +const ( + // AskUserVariantLegacy uses the legacy user-input request implementation. + AskUserVariantLegacy AskUserVariant = "legacy" + // AskUserVariantElicitation uses the elicitation-based implementation. + AskUserVariantElicitation AskUserVariant = "elicitation" +) + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID @@ -1340,8 +1350,13 @@ type SessionConfig struct { // GitHubTokenProvider acquires session-scoped GitHub tokens on demand. It // cannot be combined with GitHubToken. GitHubTokenProvider GitHubTokenProvider - // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + // OnUserInputRequest handles legacy question-and-answer requests from the agent + // and enables the legacy ask_user tool. OnUserInputRequest UserInputHandler + // AskUserVariant selects the model-facing shape of the ask_user tool. + // The zero value preserves legacy behavior. AskUserVariantElicitation also + // requires OnElicitationRequest so the host can answer structured forms. + AskUserVariant AskUserVariant // Hooks configures hook handlers for session lifecycle events Hooks *SessionHooks // WorkingDirectory is the working directory for the session. @@ -1914,8 +1929,13 @@ type ResumeSessionConfig struct { // OnMCPAuthRequest is an optional handler for MCP OAuth requests from MCP servers. // See SessionConfig.OnMCPAuthRequest. OnMCPAuthRequest MCPAuthHandler - // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + // OnUserInputRequest handles legacy question-and-answer requests from the agent + // and enables the legacy ask_user tool. OnUserInputRequest UserInputHandler + // AskUserVariant selects the model-facing shape of the ask_user tool. + // The zero value preserves legacy behavior. AskUserVariantElicitation also + // requires OnElicitationRequest so the host can answer structured forms. + AskUserVariant AskUserVariant // Hooks configures hook handlers for session lifecycle events Hooks *SessionHooks // WorkingDirectory is the working directory for the session. @@ -2509,6 +2529,7 @@ type createSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + AskUserVariant AskUserVariant `json:"askUserVariant,omitempty"` RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` @@ -2606,6 +2627,7 @@ type resumeSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + AskUserVariant AskUserVariant `json:"askUserVariant,omitempty"` RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` diff --git a/java/README.md b/java/README.md index 2e1ba5181..e64964255 100644 --- a/java/README.md +++ b/java/README.md @@ -176,6 +176,11 @@ directly. `CopilotClientOptions.setCwd(...)` sets the runtime process working directory, which otherwise inherits the current process working directory. `SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. +`SessionConfig.setAskUserVariant(AskUserVariant.ELICITATION)` selects the +structured form-based `ask_user` tool when an elicitation handler is also set. +The default is `AskUserVariant.LEGACY`. Re-supply the option and handler through +`ResumeSessionConfig` on a cold resume. + For rotating per-session GitHub credentials, use `SessionConfig.setGitHubTokenProvider(...)` (or the equivalent `ResumeSessionConfig` setter) instead of `setGitHubToken(...)`: diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec..e0a865539 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -119,6 +119,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setReasoningEffort(config.getReasoningEffort()); request.setReasoningSummary(config.getReasoningSummary()); request.setContextTier(config.getContextTier()); + request.setAskUserVariant(config.getAskUserVariant()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); @@ -255,6 +256,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setReasoningEffort(config.getReasoningEffort()); request.setReasoningSummary(config.getReasoningSummary()); request.setContextTier(config.getContextTier()); + request.setAskUserVariant(config.getAskUserVariant()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java b/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java new file mode 100644 index 000000000..17c6a1333 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Selects how the built-in {@code ask_user} tool collects user input. + */ +public enum AskUserVariant { + + /** Uses the legacy question-and-answer experience. */ + LEGACY("legacy"), + + /** Uses structured elicitation to collect user input. */ + ELICITATION("elicitation"); + + private final String value; + + AskUserVariant(String value) { + this.value = value; + } + + /** + * Returns the wire-format value. + * + * @return the value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } + + /** + * Creates an {@code AskUserVariant} from its wire-format value. + * + * @param value + * the wire-format value + * @return the matching variant, or {@code null} when {@code value} is + * {@code null} + * @throws IllegalArgumentException + * if the value is not {@code legacy} or {@code elicitation} + */ + @JsonCreator + public static AskUserVariant fromValue(String value) { + if (value == null) { + return null; + } + for (AskUserVariant variant : values()) { + if (variant.value.equals(value)) { + return variant; + } + } + throw new IllegalArgumentException("Unknown AskUserVariant value: " + value); + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 403893987..ef3531fa5 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -45,6 +45,9 @@ public final class CreateSessionRequest { @JsonProperty("contextTier") private String contextTier; + @JsonProperty("askUserVariant") + private AskUserVariant askUserVariant; + @JsonProperty("tools") private List tools; @@ -309,6 +312,16 @@ public void setContextTier(String contextTier) { this.contextTier = contextTier; } + /** Gets the ask-user variant. @return the ask-user variant */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** Sets the ask-user variant. @param askUserVariant the ask-user variant */ + public void setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a55c3454e..d13b67d71 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -63,6 +63,7 @@ public class ResumeSessionConfig { private String reasoningEffort; private String reasoningSummary; private String contextTier; + private AskUserVariant askUserVariant; private ModelCapabilitiesOverride modelCapabilities; private PermissionHandler onPermissionRequest; private McpAuthHandler onMcpAuthRequest; @@ -793,6 +794,31 @@ public ResumeSessionConfig setContextTier(String contextTier) { return this; } + /** + * Gets the experience used by the built-in {@code ask_user} tool. + * + * @return the ask-user variant, or {@code null} to use the legacy experience + */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** + * Sets the model-facing shape of the built-in {@code ask_user} tool when the + * session is resumed by a new client. + *

+ * When unset, the option is omitted and the legacy shape is used. Set an + * elicitation handler when selecting {@link AskUserVariant#ELICITATION}. + * + * @param askUserVariant + * the ask-user variant + * @return this config instance for method chaining + */ + public ResumeSessionConfig setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + return this; + } + /** * Gets the permission request handler. * @@ -2052,6 +2078,7 @@ public ResumeSessionConfig clone() { copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; + copy.askUserVariant = this.askUserVariant; copy.modelCapabilities = this.modelCapabilities; copy.onPermissionRequest = this.onPermissionRequest; copy.onUserInputRequest = this.onUserInputRequest; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 9b8e897fd..99e409043 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -47,6 +47,9 @@ public final class ResumeSessionRequest { @JsonProperty("contextTier") private String contextTier; + @JsonProperty("askUserVariant") + private AskUserVariant askUserVariant; + @JsonProperty("tools") private List tools; @@ -311,6 +314,16 @@ public void setContextTier(String contextTier) { this.contextTier = contextTier; } + /** Gets the ask-user variant. @return the ask-user variant */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** Sets the ask-user variant. @param askUserVariant the ask-user variant */ + public void setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 9f6ddb5ef..cc2a858f6 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -46,6 +46,7 @@ public class SessionConfig { private String reasoningEffort; private String reasoningSummary; private String contextTier; + private AskUserVariant askUserVariant; private List tools; private SystemMessageConfig systemMessage; private List availableTools; @@ -254,6 +255,30 @@ public SessionConfig setContextTier(String contextTier) { return this; } + /** + * Gets the experience used by the built-in {@code ask_user} tool. + * + * @return the ask-user variant, or {@code null} to use the legacy experience + */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** + * Sets the model-facing shape of the built-in {@code ask_user} tool. + *

+ * When unset, the option is omitted and the legacy shape is used. Set an + * elicitation handler when selecting {@link AskUserVariant#ELICITATION}. + * + * @param askUserVariant + * the ask-user variant + * @return this config instance for method chaining + */ + public SessionConfig setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + return this; + } + /** * Gets the custom tools for this session. * @@ -894,7 +919,9 @@ public UserInputHandler getOnUserInputRequest() { /** * Sets a handler for user input requests from the agent. *

- * When provided, enables the ask_user tool for the agent to request user input. + * When provided, enables the legacy question-and-answer form of the + * {@code ask_user} tool. Use an elicitation handler with + * {@link AskUserVariant#ELICITATION}. * * @param onUserInputRequest * the user input handler @@ -2173,6 +2200,7 @@ public SessionConfig clone() { copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; + copy.askUserVariant = this.askUserVariant; copy.tools = this.tools != null ? new ArrayList<>(this.tools) : null; copy.systemMessage = this.systemMessage; copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; diff --git a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java index 4c5a3fbef..2433e5f67 100644 --- a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -18,6 +18,7 @@ import com.github.copilot.generated.SessionEvent; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; +import com.github.copilot.rpc.AskUserVariant; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.DefaultAgentConfig; import com.github.copilot.rpc.ExitPlanModeResult; @@ -119,6 +120,7 @@ void sessionConfigCloneBasic() { original.setModel("gpt-4o"); original.setReasoningSummary("detailed"); original.setContextTier("long_context"); + original.setAskUserVariant(AskUserVariant.ELICITATION); original.setPluginDirectories(List.of("/plugins/a", "/plugins/b")); original.setDisabledMcpServers(List.of("local-files", "remote-github")); original.setLargeOutput( @@ -133,6 +135,7 @@ void sessionConfigCloneBasic() { assertEquals(original.getModel(), cloned.getModel()); assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); + assertEquals(original.getAskUserVariant(), cloned.getAskUserVariant()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); @@ -198,6 +201,7 @@ void resumeSessionConfigCloneBasic() { original.setModel("o1"); original.setReasoningSummary("none"); original.setContextTier("long_context"); + original.setAskUserVariant(AskUserVariant.LEGACY); original.setPluginDirectories(List.of("/plugins/r")); original.setDisabledMcpServers(List.of("local-files-r")); original.setLargeOutput( @@ -210,6 +214,7 @@ void resumeSessionConfigCloneBasic() { assertEquals(original.getModel(), cloned.getModel()); assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); + assertEquals(original.getAskUserVariant(), cloned.getAskUserVariant()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 9d76d18ee..5ea8c6927 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.generated.rpc.SessionLimitsConfig; +import com.github.copilot.rpc.AskUserVariant; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; @@ -78,6 +79,42 @@ void testGitHubTokenProviderResultRedactsToken() { assertFalse(result.toString().contains("do-not-print")); } + @Test + void askUserVariantIsForwardedAndSerializedForCreateAndColdResume() throws Exception { + var createRequest = SessionRequestBuilder.buildCreateRequest( + new SessionConfig().setAskUserVariant(AskUserVariant.ELICITATION), "create-session"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("resume-session", + new ResumeSessionConfig().setAskUserVariant(AskUserVariant.LEGACY)); + var mapper = JsonRpcClient.getObjectMapper(); + + assertEquals(AskUserVariant.ELICITATION, createRequest.getAskUserVariant()); + assertEquals("elicitation", + mapper.readTree(mapper.writeValueAsBytes(createRequest)).path("askUserVariant").asText()); + assertEquals(AskUserVariant.LEGACY, resumeRequest.getAskUserVariant()); + assertEquals("legacy", + mapper.readTree(mapper.writeValueAsBytes(resumeRequest)).path("askUserVariant").asText()); + } + + @Test + void askUserVariantDefaultsToOmittedLegacyBehavior() throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + var createRequest = SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "create-session"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("resume-session", new ResumeSessionConfig()); + + assertNull(createRequest.getAskUserVariant()); + assertFalse(mapper.readTree(mapper.writeValueAsBytes(createRequest)).has("askUserVariant")); + assertNull(resumeRequest.getAskUserVariant()); + assertFalse(mapper.readTree(mapper.writeValueAsBytes(resumeRequest)).has("askUserVariant")); + } + + @Test + void askUserVariantAcceptsOnlySupportedWireValues() { + assertEquals(AskUserVariant.LEGACY, AskUserVariant.fromValue("legacy")); + assertEquals(AskUserVariant.ELICITATION, AskUserVariant.fromValue("elicitation")); + assertThrows(IllegalArgumentException.class, () -> AskUserVariant.fromValue("ELICITATION")); + assertThrows(IllegalArgumentException.class, () -> AskUserVariant.fromValue("unsupported")); + } + @Test void testBuildCreateRequestHooksNonNullButEmpty() { // Hooks object exists but hasHooks() returns false diff --git a/nodejs/README.md b/nodejs/README.md index 93f9c3fa6..53e81c7aa 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -140,7 +140,8 @@ Create a new conversation session. - `gitHubTokenProvider?: GitHubTokenProvider` - Acquires rotating, session-scoped GitHub tokens. Token results require a positive `expiresIn` value in seconds remaining when the callback completes; production tokens typically last eight hours. Cannot be combined with `gitHubToken`. - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. - `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `approveAll` approves requests when managed settings are disabled and throws when `enableManagedSettings` is true. Custom handlers can inspect `managedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `onUserInputRequest?: UserInputHandler` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `askUserVariant?: "legacy" | "elicitation"` - Selects the model-facing `ask_user` tool shape when creating or cold-resuming a session. Defaults to `"legacy"`; use `"elicitation"` with `onElicitationRequest`. - `onElicitationRequest?: ElicitationHandler` - Handler for elicitation requests dispatched by the server. Enables this client to present form-based UI dialogs on behalf of the agent or other session participants. See [Elicitation Requests](#elicitation-requests) section. - `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -959,7 +960,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `skipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `onUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `onUserInputRequest` handler: ```typescript const session = await client.createSession({ @@ -991,6 +992,7 @@ Register an `onElicitationRequest` handler to let your client act as an elicitat const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, + askUserVariant: "elicitation", onElicitationRequest: async (context) => { // context.sessionId - Session that triggered the request // context.message - Description of what information is needed @@ -1012,6 +1014,9 @@ const session = await client.createSession({ console.log(session.capabilities.ui?.elicitation); // true ``` +Set `askUserVariant: "elicitation"` to expose the structured form as the model's +`ask_user` tool. Omit it to retain the legacy SDK behavior. + When `onElicitationRequest` is provided, the SDK sends `requestElicitation: true` during session create/resume, which enables `session.capabilities.ui.elicitation` on the session. In multi-client scenarios: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b853aa59..13bb9c904 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1678,6 +1678,7 @@ export class CopilotClient { requestPermission: !!config.onPermissionRequest, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } @@ -1946,6 +1947,7 @@ export class CopilotClient { config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d55ab1d1..6ca0910dd 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -53,6 +53,7 @@ export { // surface for those six identifiers is preserved unchanged. export type * from "./generated/session-events.js"; export type { + AskUserVariant, CommandContext, CommandDefinition, CommandHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a46..e6f90490e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1265,7 +1265,7 @@ export const defaultJoinSessionPermissionHandler: PermissionHandler = // ============================================================================ /** - * Request for user input from the agent (enables ask_user tool) + * Legacy question-and-answer request from the `ask_user` tool. */ export interface UserInputRequest { /** @@ -2245,6 +2245,9 @@ export interface ManagedSettings { permissions?: ManagedSettingsPermissions; } +/** Selects the model-facing shape of the built-in `ask_user` tool. */ +export type AskUserVariant = "legacy" | "elicitation"; + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -2556,10 +2559,20 @@ export interface SessionConfigBase { /** * Handler for user input requests from the agent. - * When provided, enables the ask_user tool allowing the agent to ask questions. + * When provided with the default `legacy` {@link AskUserVariant}, enables the + * question-and-answer form of the `ask_user` tool. */ onUserInputRequest?: UserInputHandler; + /** + * Selects the model-facing shape of the built-in `ask_user` tool. + * + * The default is `"legacy"`. To use `"elicitation"`, also provide + * {@link onElicitationRequest} so the host can answer structured forms. + * The runtime resolves this option when it creates or cold-resumes the session. + */ + askUserVariant?: AskUserVariant; + /** * Handler for elicitation requests from the agent. * When provided, the server calls back to this client for form-based UI dialogs. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa7..5baff82cb 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -303,6 +303,39 @@ describe("CopilotClient", () => { }); }); + it("forwards the ask-user variant on create and cold resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + 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.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + const onElicitationRequest = async () => ({ action: "decline" as const }); + + const session = await client.createSession({ + askUserVariant: "elicitation", + onElicitationRequest, + }); + await client.resumeSession(session.sessionId, { + askUserVariant: "elicitation", + onElicitationRequest, + }); + + expect(spy.mock.calls.find(([method]) => method === "session.create")![1]).toMatchObject({ + askUserVariant: "elicitation", + requestElicitation: true, + }); + expect(spy.mock.calls.find(([method]) => method === "session.resume")![1]).toMatchObject({ + askUserVariant: "elicitation", + requestElicitation: true, + }); + }); + it("omits GitHub MCP tool config when unset", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 2e85dd5af..ad2adab10 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -38,6 +38,30 @@ describe("UI Elicitation Callback", async () => { } ); + // In-process sessions do not expose current tool metadata for introspection. + it.skipIf(isInProcessTransport)( + "session created with the elicitation ask-user variant exposes the structured tool", + { timeout: 60_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + askUserVariant: "elicitation", + onElicitationRequest: async () => ({ action: "accept", content: {} }), + }); + + await session.rpc.tools.initializeAndValidate(); + const { tools } = await session.rpc.tools.getCurrentMetadata(); + const askUserSchema = tools?.find((tool) => tool.name === "ask_user")?.input_schema as + | { properties?: Record } + | undefined; + + expect(askUserSchema?.properties).toHaveProperty("message"); + expect(askUserSchema?.properties).toHaveProperty("requestedSchema"); + expect(askUserSchema?.properties).not.toHaveProperty("question"); + await session.disconnect(); + } + ); + it( "session created without onElicitationRequest reports no elicitation capability", { timeout: 60_000 }, diff --git a/python/README.md b/python/README.md index 61608c16a..c130f21c6 100644 --- a/python/README.md +++ b/python/README.md @@ -283,7 +283,8 @@ These are passed as keyword arguments to `create_session()`: - `enable_session_store` (bool): Enables the cross-session store for search and retrieval across sessions. When unset in `"copilot-cli"` mode, the runtime default applies (enabled). In `"empty"` mode, defaults to disabled. - `github_token_provider` (callable): Acquires rotating, session-scoped GitHub tokens. Token results require a positive `expiresIn` value in seconds remaining when the callback completes; production tokens typically last eight hours. Cannot be combined with `github_token`. - `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves requests when managed settings are disabled and raises an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `on_user_input_request` (callable): Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `ask_user_variant` (`"legacy"` | `"elicitation"`): Selects the model-facing shape of the `ask_user` tool. Defaults to `"legacy"`; use `"elicitation"` with `on_elicitation_request`. Re-supply this option when cold-resuming a session. - `hooks` (SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ```python @@ -899,7 +900,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `skip_p ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `on_user_input_request` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `on_user_input_request` handler: ```python async def handle_user_input(request, invocation): diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 608dacf25..4b3b9901e 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -29,6 +29,7 @@ OpenCanvasInstance, ) from .client import ( + AskUserVariant, CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, @@ -229,6 +230,7 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "AskUserVariant", "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", diff --git a/python/copilot/client.py b/python/copilot/client.py index 271fad626..05412246d 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -177,6 +177,8 @@ class GitHubTokenCancelledResult(TypedDict): _ConnectionState = Literal["disconnected", "connecting", "connected", "error"] LogLevel = Literal["none", "error", "warning", "info", "debug", "all"] +AskUserVariant = Literal["legacy", "elicitation"] +"""Model-facing shape of the runtime's built-in ``ask_user`` tool.""" @dataclass @@ -2209,6 +2211,7 @@ async def create_session( available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, + ask_user_variant: AskUserVariant | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, additional_directories: list[str] | None = None, @@ -2311,6 +2314,10 @@ async def create_session( including custom tools registered via ``tools=``. Ignored if ``available_tools`` is set. on_user_input_request: Handler for user input requests. + ask_user_variant: Model-facing shape of the ``ask_user`` tool. + Accepted values are ``"legacy"`` and ``"elicitation"``. The + default is ``"legacy"``. To use ``"elicitation"``, also provide + ``on_elicitation_request`` so the host can answer structured forms. hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. @@ -2465,6 +2472,8 @@ async def create_session( raise ValueError("on_permission_request must be callable when provided.") if github_token is not None and github_token_provider is not None: raise ValueError("github_token and github_token_provider are mutually exclusive") + if ask_user_variant not in (None, "legacy", "elicitation"): + raise ValueError('ask_user_variant must be "legacy" or "elicitation"') if not self._client: await self.start() @@ -2552,6 +2561,8 @@ async def create_session( # Enable user input request callback if handler provided if on_user_input_request: payload["requestUserInput"] = True + if ask_user_variant is not None: + payload["askUserVariant"] = ask_user_variant # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) @@ -2970,6 +2981,7 @@ async def resume_session( available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, + ask_user_variant: AskUserVariant | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, additional_directories: list[str] | None = None, @@ -3073,6 +3085,10 @@ async def resume_session( including custom tools registered via ``tools=``. Ignored if ``available_tools`` is set. on_user_input_request: Handler for user input requests. + ask_user_variant: Model-facing shape of the ``ask_user`` tool. + Accepted values are ``"legacy"`` and ``"elicitation"``. The + default is ``"legacy"``. To use ``"elicitation"``, also provide + ``on_elicitation_request`` so the host can answer structured forms. hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. @@ -3226,6 +3242,8 @@ async def resume_session( raise ValueError("on_permission_request must be callable when provided.") if github_token is not None and github_token_provider is not None: raise ValueError("github_token and github_token_provider are mutually exclusive") + if ask_user_variant not in (None, "legacy", "elicitation"): + raise ValueError('ask_user_variant must be "legacy" or "elicitation"') if not self._client: await self.start() @@ -3341,6 +3359,8 @@ async def resume_session( if on_user_input_request: payload["requestUserInput"] = True + if ask_user_variant is not None: + payload["askUserVariant"] = ask_user_variant # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) diff --git a/python/copilot/session.py b/python/copilot/session.py index 78afdde13..3c6d3d54a 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -504,7 +504,7 @@ class McpAuthContext(TypedDict): class UserInputRequest(TypedDict, total=False): - """Request for user input from the agent (enables ask_user tool)""" + """Legacy question-and-answer request from the ask_user tool.""" question: str choices: list[str] diff --git a/python/test_client.py b/python/test_client.py index a33f0ecd6..bfcee83db 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -211,6 +211,61 @@ async def test_resume_session_allows_none_permission_handler(self): class TestCreateSessionConfig: + @pytest.mark.asyncio + async def test_ask_user_variant_forwarded_on_create_and_cold_resume(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured: list[tuple[str, dict]] = [] + + async def mock_request(method, params, **kwargs): + captured.append((method, params)) + result = {"sessionId": params["sessionId"], "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + + client._client.request = mock_request + await client.create_session( + session_id="ask-user-create", + ask_user_variant="elicitation", + ) + await client.resume_session( + "ask-user-cold-resume", + ask_user_variant="legacy", + ) + await client.create_session(session_id="ask-user-default-create") + await client.resume_session("ask-user-default-cold-resume") + + payloads = {(method, params["sessionId"]): params for method, params in captured} + assert ( + payloads[("session.create", "ask-user-create")]["askUserVariant"] == "elicitation" + ) + assert ( + payloads[("session.resume", "ask-user-cold-resume")]["askUserVariant"] == "legacy" + ) + assert "askUserVariant" not in payloads[("session.create", "ask-user-default-create")] + assert ( + "askUserVariant" not in payloads[("session.resume", "ask-user-default-cold-resume")] + ) + finally: + await client.force_stop() + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create", "resume"]) + async def test_ask_user_variant_rejects_unknown_values(self, method): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + + with pytest.raises(ValueError, match="ask_user_variant"): + if method == "create": + await client.create_session(ask_user_variant="unknown") # type: ignore[arg-type] + else: + await client.resume_session( + "ask-user-cold-resume", + ask_user_variant="unknown", # type: ignore[arg-type] + ) + @pytest.mark.asyncio async def test_additional_directories_forwarded_on_create_and_resume(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/README.md b/rust/README.md index 323d525d3..6114105c2 100644 --- a/rust/README.md +++ b/rust/README.md @@ -274,6 +274,11 @@ let config = SessionConfig { let session = client.create_session(config).await?; ``` +Use `with_ask_user_variant(AskUserVariant::Elicitation)` together with +`with_elicitation_handler(...)` to expose the structured form-based `ask_user` +tool. The default remains `AskUserVariant::Legacy`. Re-supply the option and +handler through `ResumeSessionConfig` on a cold resume. + For rotating per-session GitHub credentials, install a `GitHubTokenProvider` instead of setting `github_token`: @@ -521,6 +526,7 @@ impl ElicitationHandler for MyElicitation { let config = SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_ask_user_variant(AskUserVariant::Elicitation) .with_elicitation_handler(Arc::new(MyElicitation)); ``` diff --git a/rust/src/handler.rs b/rust/src/handler.rs index e036b75a1..61d9b192c 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -294,10 +294,11 @@ pub trait McpAuthHandler: Send + Sync + 'static { ) -> McpAuthResult; } -/// Handler for `user_input.requested` events from the `ask_user` tool. +/// Handler for `user_input.requested` events from the legacy question-and-answer +/// `ask_user` variant. /// -/// When unset, `requestUserInput: false` goes on the wire and the -/// `ask_user` tool is disabled for the session. +/// When unset, `requestUserInput: false` goes on the wire, so this client +/// cannot handle legacy user-input requests. #[async_trait] pub trait UserInputHandler: Send + Sync + 'static { /// Answer a question on behalf of the user. Return `None` to signal diff --git a/rust/src/types.rs b/rust/src/types.rs index 6e451eb45..726bc48f4 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1851,6 +1851,18 @@ impl ManagedSettings { } } +/// Selects the model-facing shape of the built-in `ask_user` tool. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum AskUserVariant { + /// Use the legacy user-input request flow. + #[default] + Legacy, + /// Use the elicitation request flow. + Elicitation, +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1924,6 +1936,11 @@ pub struct SessionConfig { pub streaming: Option, /// Custom system message configuration. pub system_message: Option, + /// Selects the model-facing shape of the built-in `ask_user` tool. + /// + /// When omitted, the runtime uses [`AskUserVariant::Legacy`]. To use + /// [`AskUserVariant::Elicitation`], also install an [`ElicitationHandler`]. + pub ask_user_variant: Option, /// Client-defined tool declarations to expose to the agent. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. @@ -2181,9 +2198,9 @@ pub struct SessionConfig { /// Optional MCP OAuth request handler. When set, the SDK can satisfy MCP /// server OAuth requests with host-acquired token data or cancellation. pub mcp_auth_handler: Option>, - /// Optional user-input handler. When `None`, - /// `requestUserInput: false` goes on the wire and the `ask_user` - /// tool is disabled. + /// Optional handler for the legacy question-and-answer `ask_user` variant. + /// When `None`, `requestUserInput: false` goes on the wire, so this client + /// cannot handle legacy user-input requests. pub user_input_handler: Option>, /// Optional exit-plan-mode handler. When `None`, /// `requestExitPlanMode: false` goes on the wire. @@ -2238,6 +2255,7 @@ impl std::fmt::Debug for SessionConfig { .field("context_tier", &self.context_tier) .field("streaming", &self.streaming) .field("system_message", &self.system_message) + .field("ask_user_variant", &self.ask_user_variant) .field("tools", &self.tools) .field("canvases", &self.canvases) .field( @@ -2378,6 +2396,7 @@ impl Default for SessionConfig { context_tier: None, streaming: None, system_message: None, + ask_user_variant: None, tools: None, canvases: None, canvas_handler: None, @@ -2545,6 +2564,7 @@ impl SessionConfig { context_tier: self.context_tier, streaming: self.streaming, system_message: self.system_message, + ask_user_variant: self.ask_user_variant, tools: self.tools, canvases: wire_canvases, request_canvas_renderer: self.request_canvas_renderer, @@ -2656,13 +2676,19 @@ impl SessionConfig { self } - /// Install a [`UserInputHandler`]. Required for the `ask_user` tool - /// to be enabled. + /// Install a [`UserInputHandler`] for the legacy question-and-answer + /// `ask_user` variant. pub fn with_user_input_handler(mut self, handler: Arc) -> Self { self.user_input_handler = Some(handler); self } + /// Select the model-facing shape of the built-in `ask_user` tool. + pub fn with_ask_user_variant(mut self, variant: AskUserVariant) -> Self { + self.ask_user_variant = Some(variant); + self + } + /// Install an [`ExitPlanModeHandler`]. pub fn with_exit_plan_mode_handler(mut self, handler: Arc) -> Self { self.exit_plan_mode_handler = Some(handler); @@ -3304,6 +3330,11 @@ pub struct ResumeSessionConfig { /// Re-supply the system message so the agent retains workspace context /// across CLI process restarts. pub system_message: Option, + /// Selects the model-facing shape of the built-in `ask_user` tool on a cold resume. + /// + /// When omitted, the runtime uses [`AskUserVariant::Legacy`]. To use + /// [`AskUserVariant::Elicitation`], also install an [`ElicitationHandler`]. + pub ask_user_variant: Option, /// Client-defined tool declarations to re-supply on resume. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. @@ -3547,6 +3578,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("context_tier", &self.context_tier) .field("streaming", &self.streaming) .field("system_message", &self.system_message) + .field("ask_user_variant", &self.ask_user_variant) .field("tools", &self.tools) .field("canvases", &self.canvases) .field( @@ -3730,6 +3762,7 @@ impl ResumeSessionConfig { context_tier: self.context_tier, streaming: self.streaming, system_message: self.system_message, + ask_user_variant: self.ask_user_variant, tools: self.tools, canvases: wire_canvases, open_canvases: self.open_canvases, @@ -3836,6 +3869,7 @@ impl ResumeSessionConfig { context_tier: None, streaming: None, system_message: None, + ask_user_variant: None, tools: None, canvases: None, canvas_handler: None, @@ -3939,6 +3973,12 @@ impl ResumeSessionConfig { self } + /// Select the model-facing shape of the built-in `ask_user` tool on resume. + pub fn with_ask_user_variant(mut self, variant: AskUserVariant) -> Self { + self.ask_user_variant = Some(variant); + self + } + /// Install an [`ExitPlanModeHandler`] for the resumed session. pub fn with_exit_plan_mode_handler(mut self, handler: Arc) -> Self { self.exit_plan_mode_handler = Some(handler); @@ -6239,6 +6279,8 @@ mod tests { assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); assert!(!wire.request_mcp_apps); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("askUserVariant").is_none()); } #[test] @@ -6255,6 +6297,8 @@ mod tests { assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); assert!(!wire.request_mcp_apps); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("askUserVariant").is_none()); } #[test] diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f7de33839..ebc050477 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -24,11 +24,11 @@ use crate::generated::api_types::{ }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ - CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, - DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, InfiniteSessionConfig, - LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, NamedProviderConfig, - ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, SystemMessageConfig, Tool, - ToolSearchConfig, + AskUserVariant, CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, + CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, + InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, + NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, + SystemMessageConfig, Tool, ToolSearchConfig, }; /// Wire representation of a slash command (name + description only). The @@ -64,6 +64,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub system_message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ask_user_variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub canvases: Option>, @@ -218,6 +220,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub system_message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ask_user_variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub canvases: Option>, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index a51d61910..fb16a0f67 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -24,8 +24,8 @@ use github_copilot_sdk::session_events::{ SessionManagedSettingsResolvedData, }; use github_copilot_sdk::types::{ - CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, - CommandDefinition, CommandHandler, DeliveryMode, DisableBypassPermissionsModes, + AskUserVariant, CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, + CommandContext, CommandDefinition, CommandHandler, DeliveryMode, DisableBypassPermissionsModes, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, ManagedSettings, ManagedSettingsPermissions, MessageOptions, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, RequestId, @@ -918,6 +918,60 @@ async fn create_session_sends_new_session_options() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn create_session_forwards_ask_user_variant() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default().with_ask_user_variant(AskUserVariant::Elicitation), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["askUserVariant"], "elicitation"); + + let session_id = requested_session_id(&request).to_string(); + server_respond_create(&mut server_write, &request, &session_id).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn cold_resume_session_forwards_ask_user_variant() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session( + ResumeSessionConfig::new(SessionId::from("ask-user-variant")) + .with_ask_user_variant(AskUserVariant::Legacy), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["sessionId"], "ask-user-variant"); + assert_eq!(request["params"]["askUserVariant"], "legacy"); + + server_respond_create(&mut server_write, &request, "ask-user-variant").await; + respond_to_reload(&mut server_read, &mut server_write).await; + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + #[tokio::test] async fn resume_session_sends_new_session_options() { use github_copilot_sdk::types::ResumeSessionConfig;