Skip to content

Commit dd42d42

Browse files
Close Language Gaps for Commands + Dialogs/Elicitations (#960)
* Close Language Gaps for Commands + Dialogs/Elicitations * Fix code quality review feedback and formatting issues - Python: fix ruff formatting, add comments to empty except blocks, remove unused imports - .NET: simplify boolean expressions, combine nested ifs, narrow generic catch clause - Go: fix struct field alignment for go fmt compliance * Fix Python ruff lint errors: unused imports, import sort order, line length * Fix Python type checker errors: remove unused type-ignore comments, fix capabilities type * Fix Go struct field alignment for go fmt compliance * Fix Python E2E tests: use correct snapshot directory 'multi_client' * Skip flaky Python E2E disconnect test: force_stop() doesn't trigger capabilities.changed reliably in replay proxy * fix: close makefile wrapper in Python force_stop() to trigger TCP disconnect Python's socket.makefile() holds its own reference to the socket. Calling socket.close() alone won't release the OS-level resource until the makefile wrapper is also closed. This meant force_stop() wasn't actually closing the TCP connection, so the server never detected the disconnect and never sent capabilities.changed events to other clients. Fix: close the file wrapper before the socket in SocketWrapper.terminate(). Unskip test_capabilities_changed_when_elicitation_provider_disconnects. * fix: address remaining code review feedback - Narrow generic catch clauses in .NET command/elicitation handlers with 'when (ex is not OperationCanceledException)' filter - Remove redundant null-conditional (val?.ToString -> val.ToString) in SelectAsync and InputAsync switch expressions - Add explanatory comments to Python empty except blocks * fix: use socket.shutdown() in Python force_stop() for reliable disconnect socket.close() and file wrapper close don't reliably interrupt a blocking readline() on another thread in Python. socket.shutdown(SHUT_RDWR) sends TCP FIN to the server immediately (triggering server-side disconnect detection) and interrupts any pending blocking reads across threads — matching Node.js socket.destroy() and Go conn.Close() behavior. * chore: remove working markdown files from PR * fix: pass full elicitation schema in Go, add schema tests across SDKs Go was only passing RequestedSchema.Properties to the elicitation handler, dropping the 'type' and 'required' fields. This meant handlers couldn't reconstruct the full JSON Schema. Now passes a complete map with type, properties, and required. Also replaces custom containsString/searchSubstring helpers in Go tests with strings.Contains, and adds tests in Go and Python that verify the full schema is passed through to elicitation handlers. * fix: Go test compilation errors for schema extraction test Use direct schema extraction logic test instead of dispatching through session event machinery, avoiding need for RPC mocks. Fixes undefined SessionEventData and handleEvent references. * fix: resolve staticcheck SA4031 lint in Go schema test * test: add Go command error, unknown command, and elicitation handler tests - Command handler error propagation: verifies handler error is returned - Unknown command: verifies getCommandHandler returns false for unknown - Elicitation handler error: verifies error propagation from handler - Elicitation handler success: verifies result with action and content * fix: remove redundant nil check flagged by staticcheck SA4031 * docs: promote Commands and UI Elicitation to top-level sections in .NET README Matches Go and Python README structure where these are ## (h2) sections rather than ### (h3) subsections. Closes documentation gap flagged by SDK Consistency Review Agent. * fix: address human review feedback .NET: - Cache SessionUiApiImpl instance via Lazy<> instead of allocating on every .Ui access - Await command/elicitation handler calls instead of redundant fire-and-forget (outer caller already fire-and-forgets) - Use ElicitationRequestedDataMode enum for Mode instead of string Go: - Handle SessionEventTypeCapabilitiesChanged in handleBroadcastEvent to update session capabilities when other clients join/leave with elicitation handlers - Add test verifying capabilities.changed event updates session * refactor: merge ElicitationRequest + ElicitationInvocation into ElicitationContext Combines the two-argument elicitation handler pattern into a single ElicitationContext type across all three SDKs, matching the existing CommandContext pattern. The context now includes SessionId alongside the request fields (Message, RequestedSchema, Mode, etc.). Changes per language: - .NET: ElicitationContext class, single-arg delegate, Lazy<> cached Ui - Go: ElicitationContext struct, single-arg handler func - Python: ElicitationContext TypedDict, single-arg callable All tests, READMEs, and E2E tests updated. * refactor: apply ElicitationContext rename to Node.js SDK Consistent with Python, Go, and .NET — ElicitationRequest is now ElicitationContext with sessionId included. Handler takes single arg. Completes the cross-SDK consistency change. * style: fix formatting (prettier, ruff, trailing newlines) * style: fix Python import sort order in __init__.py * fix: simplify Ui auto-property and remove empty snapshot files - Replace Lazy<ISessionUiApi> with simple auto-property initialized in constructor, per reviewer feedback - Delete 14 empty snapshot YAML files (commands, elicitation, multi_client) that had no conversation data * fix: rename misleading command test names Renamed to accurately reflect what they verify: - Forwards_Commands_In_Session_Create -> Session_With_Commands_Creates_Successfully - Forwards_Commands_In_Session_Resume -> Session_With_Commands_Resumes_Successfully Actual forwarding verification is in the multi-client test Client_Receives_Commands_Changed_When_Another_Client_Joins_With_Commands which proves the server received the commands by checking the commands.changed event on another client. * fix: remove leftover JSDoc from ElicitationRequest rename
1 parent ad63b09 commit dd42d42

27 files changed

+5064
-88
lines changed

dotnet/README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,95 @@ var safeLookup = AIFunctionFactory.Create(
488488
});
489489
```
490490

491+
## Commands
492+
493+
Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `Name`, optional `Description`, and a `Handler` called when the user executes it.
494+
495+
```csharp
496+
var session = await client.CreateSessionAsync(new SessionConfig
497+
{
498+
Model = "gpt-5",
499+
OnPermissionRequest = PermissionHandler.ApproveAll,
500+
Commands =
501+
[
502+
new CommandDefinition
503+
{
504+
Name = "deploy",
505+
Description = "Deploy the app to production",
506+
Handler = async (context) =>
507+
{
508+
Console.WriteLine($"Deploying with args: {context.Args}");
509+
// Do work here — any thrown error is reported back to the CLI
510+
},
511+
},
512+
],
513+
});
514+
```
515+
516+
When the user types `/deploy staging` in the CLI, the SDK receives a `command.execute` event, routes it to your handler, and automatically responds to the CLI. If the handler throws, the error message is forwarded.
517+
518+
Commands are sent to the CLI on both `CreateSessionAsync` and `ResumeSessionAsync`, so you can update the command set when resuming.
519+
520+
## UI Elicitation
521+
522+
When the session has elicitation support — either from the CLI's TUI or from another client that registered an `OnElicitationRequest` handler (see [Elicitation Requests](#elicitation-requests)) — the SDK can request interactive form dialogs from the user. The `session.Ui` object provides convenience methods built on a single generic elicitation RPC.
523+
524+
> **Capability check:** Elicitation is only available when at least one connected participant advertises support. Always check `session.Capabilities.Ui?.Elicitation` before calling UI methods — this property updates automatically as participants join and leave.
525+
526+
```csharp
527+
var session = await client.CreateSessionAsync(new SessionConfig
528+
{
529+
Model = "gpt-5",
530+
OnPermissionRequest = PermissionHandler.ApproveAll,
531+
});
532+
533+
if (session.Capabilities.Ui?.Elicitation == true)
534+
{
535+
// Confirm dialog — returns boolean
536+
bool ok = await session.Ui.ConfirmAsync("Deploy to production?");
537+
538+
// Selection dialog — returns selected value or null
539+
string? env = await session.Ui.SelectAsync("Pick environment",
540+
["production", "staging", "dev"]);
541+
542+
// Text input — returns string or null
543+
string? name = await session.Ui.InputAsync("Project name:", new InputOptions
544+
{
545+
Title = "Name",
546+
MinLength = 1,
547+
MaxLength = 50,
548+
});
549+
550+
// Generic elicitation with full schema control
551+
ElicitationResult result = await session.Ui.ElicitationAsync(new ElicitationParams
552+
{
553+
Message = "Configure deployment",
554+
RequestedSchema = new ElicitationSchema
555+
{
556+
Type = "object",
557+
Properties = new Dictionary<string, object>
558+
{
559+
["region"] = new Dictionary<string, object>
560+
{
561+
["type"] = "string",
562+
["enum"] = new[] { "us-east", "eu-west" },
563+
},
564+
["dryRun"] = new Dictionary<string, object>
565+
{
566+
["type"] = "boolean",
567+
["default"] = true,
568+
},
569+
},
570+
Required = ["region"],
571+
},
572+
});
573+
// result.Action: Accept, Decline, or Cancel
574+
// result.Content: { "region": "us-east", "dryRun": true } (when accepted)
575+
}
576+
```
577+
578+
All UI methods throw if elicitation is not supported by the host.
579+
491580
### System Message Customization
492581

493582
Control the system prompt using `SystemMessage` in session config:
@@ -812,6 +901,50 @@ var session = await client.CreateSessionAsync(new SessionConfig
812901
- `OnSessionEnd` - Cleanup or logging when session ends.
813902
- `OnErrorOccurred` - Handle errors with retry/skip/abort strategies.
814903

904+
## Elicitation Requests
905+
906+
Register an `OnElicitationRequest` handler to let your client act as an elicitation provider — presenting form-based UI dialogs on behalf of the agent. When provided, the server notifies your client whenever a tool or MCP server needs structured user input.
907+
908+
```csharp
909+
var session = await client.CreateSessionAsync(new SessionConfig
910+
{
911+
Model = "gpt-5",
912+
OnPermissionRequest = PermissionHandler.ApproveAll,
913+
OnElicitationRequest = async (context) =>
914+
{
915+
// context.SessionId - Session that triggered the request
916+
// context.Message - Description of what information is needed
917+
// context.RequestedSchema - JSON Schema describing the form fields
918+
// context.Mode - "form" (structured input) or "url" (browser redirect)
919+
// context.ElicitationSource - Origin of the request (e.g. MCP server name)
920+
921+
Console.WriteLine($"Elicitation from {context.ElicitationSource}: {context.Message}");
922+
923+
// Present UI to the user and collect their response...
924+
return new ElicitationResult
925+
{
926+
Action = SessionUiElicitationResultAction.Accept,
927+
Content = new Dictionary<string, object>
928+
{
929+
["region"] = "us-east",
930+
["dryRun"] = true,
931+
},
932+
};
933+
},
934+
});
935+
936+
// The session now reports elicitation capability
937+
Console.WriteLine(session.Capabilities.Ui?.Elicitation); // True
938+
```
939+
940+
When `OnElicitationRequest` is provided, the SDK sends `RequestElicitation = true` during session create/resume, which enables `session.Capabilities.Ui.Elicitation` on the session.
941+
942+
In multi-client scenarios:
943+
944+
- If no connected client was previously providing an elicitation capability, but a new client joins that can, all clients will receive a `capabilities.changed` event to notify them that elicitation is now possible. The SDK automatically updates `session.Capabilities` when these events arrive.
945+
- Similarly, if the last elicitation provider disconnects, all clients receive a `capabilities.changed` event indicating elicitation is no longer available.
946+
- The server fans out elicitation requests to **all** connected clients that registered a handler — the first response wins.
947+
815948
## Error Handling
816949

817950
```csharp

dotnet/src/Client.cs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
456456
var session = new CopilotSession(sessionId, connection.Rpc, _logger);
457457
session.RegisterTools(config.Tools ?? []);
458458
session.RegisterPermissionHandler(config.OnPermissionRequest);
459+
session.RegisterCommands(config.Commands);
460+
session.RegisterElicitationHandler(config.OnElicitationRequest);
459461
if (config.OnUserInputRequest != null)
460462
{
461463
session.RegisterUserInputHandler(config.OnUserInputRequest);
@@ -501,13 +503,16 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
501503
config.SkillDirectories,
502504
config.DisabledSkills,
503505
config.InfiniteSessions,
504-
traceparent,
505-
tracestate);
506+
Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(),
507+
RequestElicitation: config.OnElicitationRequest != null,
508+
Traceparent: traceparent,
509+
Tracestate: tracestate);
506510

507511
var response = await InvokeRpcAsync<CreateSessionResponse>(
508512
connection.Rpc, "session.create", [request], cancellationToken);
509513

510514
session.WorkspacePath = response.WorkspacePath;
515+
session.SetCapabilities(response.Capabilities);
511516
}
512517
catch
513518
{
@@ -570,6 +575,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
570575
var session = new CopilotSession(sessionId, connection.Rpc, _logger);
571576
session.RegisterTools(config.Tools ?? []);
572577
session.RegisterPermissionHandler(config.OnPermissionRequest);
578+
session.RegisterCommands(config.Commands);
579+
session.RegisterElicitationHandler(config.OnElicitationRequest);
573580
if (config.OnUserInputRequest != null)
574581
{
575582
session.RegisterUserInputHandler(config.OnUserInputRequest);
@@ -616,13 +623,16 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
616623
config.SkillDirectories,
617624
config.DisabledSkills,
618625
config.InfiniteSessions,
619-
traceparent,
620-
tracestate);
626+
Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(),
627+
RequestElicitation: config.OnElicitationRequest != null,
628+
Traceparent: traceparent,
629+
Tracestate: tracestate);
621630

622631
var response = await InvokeRpcAsync<ResumeSessionResponse>(
623632
connection.Rpc, "session.resume", [request], cancellationToken);
624633

625634
session.WorkspacePath = response.WorkspacePath;
635+
session.SetCapabilities(response.Capabilities);
626636
}
627637
catch
628638
{
@@ -1592,6 +1602,8 @@ internal record CreateSessionRequest(
15921602
List<string>? SkillDirectories,
15931603
List<string>? DisabledSkills,
15941604
InfiniteSessionConfig? InfiniteSessions,
1605+
List<CommandWireDefinition>? Commands = null,
1606+
bool? RequestElicitation = null,
15951607
string? Traceparent = null,
15961608
string? Tracestate = null);
15971609

@@ -1614,7 +1626,8 @@ public static ToolDefinition FromAIFunction(AIFunction function)
16141626

16151627
internal record CreateSessionResponse(
16161628
string SessionId,
1617-
string? WorkspacePath);
1629+
string? WorkspacePath,
1630+
SessionCapabilities? Capabilities = null);
16181631

16191632
internal record ResumeSessionRequest(
16201633
string SessionId,
@@ -1640,12 +1653,19 @@ internal record ResumeSessionRequest(
16401653
List<string>? SkillDirectories,
16411654
List<string>? DisabledSkills,
16421655
InfiniteSessionConfig? InfiniteSessions,
1656+
List<CommandWireDefinition>? Commands = null,
1657+
bool? RequestElicitation = null,
16431658
string? Traceparent = null,
16441659
string? Tracestate = null);
16451660

16461661
internal record ResumeSessionResponse(
16471662
string SessionId,
1648-
string? WorkspacePath);
1663+
string? WorkspacePath,
1664+
SessionCapabilities? Capabilities = null);
1665+
1666+
internal record CommandWireDefinition(
1667+
string Name,
1668+
string? Description);
16491669

16501670
internal record GetLastSessionIdResponse(
16511671
string? SessionId);
@@ -1782,9 +1802,12 @@ private static LogLevel MapLevel(TraceEventType eventType)
17821802
[JsonSerializable(typeof(ProviderConfig))]
17831803
[JsonSerializable(typeof(ResumeSessionRequest))]
17841804
[JsonSerializable(typeof(ResumeSessionResponse))]
1805+
[JsonSerializable(typeof(SessionCapabilities))]
1806+
[JsonSerializable(typeof(SessionUiCapabilities))]
17851807
[JsonSerializable(typeof(SessionMetadata))]
17861808
[JsonSerializable(typeof(SystemMessageConfig))]
17871809
[JsonSerializable(typeof(SystemMessageTransformRpcResponse))]
1810+
[JsonSerializable(typeof(CommandWireDefinition))]
17881811
[JsonSerializable(typeof(ToolCallResponseV2))]
17891812
[JsonSerializable(typeof(ToolDefinition))]
17901813
[JsonSerializable(typeof(ToolResultAIContent))]

0 commit comments

Comments
 (0)