Skip to content

Commit 1603120

Browse files
.NET SDK: protocol v3 broadcast model migration
- Bump protocol version 2→3 in SdkProtocolVersion.cs - Remove tool.call and permission.request RPC handlers from Client.cs - Remove ToolCallResponse and PermissionRequestResponse internal types - Add broadcast event handling in Session.cs: - HandleBroadcastEventAsync dispatches ExternalToolRequestedEvent and PermissionRequestedEvent - ExecuteToolAndRespondAsync invokes AIFunction and responds via HandlePendingToolCallAsync - ExecutePermissionAndRespondAsync invokes permission handler and responds via HandlePendingPermissionRequestAsync - Add ActualPort property to CopilotClient for multi-client TCP tests - Add E2ETestContext.CreateClient(useStdio) overload - Add MultiClientTests.cs with 5 E2E tests: - Both clients see tool events - Permission approve - Permission reject - Multi-client tool union - Disconnect cleanup Codegen fixes (C# and Go): - Prefix server-scoped API classes with 'Server' to avoid naming collision with session-scoped classes (e.g., ServerToolsApi vs ToolsApi) - Add default value (null!) for required object properties in C# codegen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8ba1a30 commit 1603120

File tree

9 files changed

+514
-150
lines changed

9 files changed

+514
-150
lines changed

dotnet/src/Client.cs

Lines changed: 8 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
6161
private bool _disposed;
6262
private readonly int? _optionsPort;
6363
private readonly string? _optionsHost;
64+
private int? _actualPort;
6465
private List<ModelInfo>? _modelsCache;
6566
private readonly SemaphoreSlim _modelsCacheLock = new(1, 1);
6667
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
@@ -80,6 +81,11 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
8081
? throw new ObjectDisposedException(nameof(CopilotClient))
8182
: _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first.");
8283

84+
/// <summary>
85+
/// Gets the actual TCP port the CLI server is listening on, if using TCP transport.
86+
/// </summary>
87+
public int? ActualPort => _actualPort;
88+
8389
/// <summary>
8490
/// Creates a new instance of <see cref="CopilotClient"/>.
8591
/// </summary>
@@ -191,12 +197,14 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
191197
if (_optionsHost is not null && _optionsPort is not null)
192198
{
193199
// External server (TCP)
200+
_actualPort = _optionsPort;
194201
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
195202
}
196203
else
197204
{
198205
// Child process (stdio or TCP)
199206
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
207+
_actualPort = portOrNull;
200208
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
201209
}
202210

@@ -1129,8 +1137,6 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
11291137
var handler = new RpcHandler(this);
11301138
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
11311139
rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle);
1132-
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
1133-
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
11341140
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
11351141
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
11361142
rpc.StartListening();
@@ -1231,116 +1237,6 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad
12311237
client.DispatchLifecycleEvent(evt);
12321238
}
12331239

1234-
public async Task<ToolCallResponse> OnToolCall(string sessionId,
1235-
string toolCallId,
1236-
string toolName,
1237-
object? arguments)
1238-
{
1239-
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1240-
if (session.GetTool(toolName) is not { } tool)
1241-
{
1242-
return new ToolCallResponse(new ToolResultObject
1243-
{
1244-
TextResultForLlm = $"Tool '{toolName}' is not supported.",
1245-
ResultType = "failure",
1246-
Error = $"tool '{toolName}' not supported"
1247-
});
1248-
}
1249-
1250-
try
1251-
{
1252-
var invocation = new ToolInvocation
1253-
{
1254-
SessionId = sessionId,
1255-
ToolCallId = toolCallId,
1256-
ToolName = toolName,
1257-
Arguments = arguments
1258-
};
1259-
1260-
// Map args from JSON into AIFunction format
1261-
var aiFunctionArgs = new AIFunctionArguments
1262-
{
1263-
Context = new Dictionary<object, object?>
1264-
{
1265-
// Allow recipient to access the raw ToolInvocation if they want, e.g., to get SessionId
1266-
// This is an alternative to using MEAI's ConfigureParameterBinding, which we can't use
1267-
// because we're not the ones producing the AIFunction.
1268-
[typeof(ToolInvocation)] = invocation
1269-
}
1270-
};
1271-
1272-
if (arguments is not null)
1273-
{
1274-
if (arguments is not JsonElement incomingJsonArgs)
1275-
{
1276-
throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}");
1277-
}
1278-
1279-
foreach (var prop in incomingJsonArgs.EnumerateObject())
1280-
{
1281-
// MEAI will deserialize the JsonElement value respecting the delegate's parameter types
1282-
aiFunctionArgs[prop.Name] = prop.Value;
1283-
}
1284-
}
1285-
1286-
var result = await tool.InvokeAsync(aiFunctionArgs);
1287-
1288-
// If the function returns a ToolResultObject, use it directly; otherwise, wrap the result
1289-
// This lets the developer provide BinaryResult, SessionLog, etc. if they deal with that themselves
1290-
var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
1291-
{
1292-
ResultType = "success",
1293-
1294-
// In most cases, result will already have been converted to JsonElement by the AIFunction.
1295-
// We special-case string for consistency with our Node/Python/Go clients.
1296-
// TODO: I don't think it's right to special-case string here, and all the clients should
1297-
// always serialize the result to JSON (otherwise what stringification is going to happen?
1298-
// something we don't control? an error?)
1299-
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
1300-
? je.GetString()!
1301-
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
1302-
};
1303-
return new ToolCallResponse(toolResultObject);
1304-
}
1305-
catch (Exception ex)
1306-
{
1307-
return new ToolCallResponse(new()
1308-
{
1309-
// TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM.
1310-
// For security, the default must be false, but developers can opt into allowing it.
1311-
TextResultForLlm = $"Invoking this tool produced an error. Detailed information is not available.",
1312-
ResultType = "failure",
1313-
Error = ex.Message
1314-
});
1315-
}
1316-
}
1317-
1318-
public async Task<PermissionRequestResponse> OnPermissionRequest(string sessionId, JsonElement permissionRequest)
1319-
{
1320-
var session = client.GetSession(sessionId);
1321-
if (session == null)
1322-
{
1323-
return new PermissionRequestResponse(new PermissionRequestResult
1324-
{
1325-
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
1326-
});
1327-
}
1328-
1329-
try
1330-
{
1331-
var result = await session.HandlePermissionRequestAsync(permissionRequest);
1332-
return new PermissionRequestResponse(result);
1333-
}
1334-
catch
1335-
{
1336-
// If permission handler fails, deny the permission
1337-
return new PermissionRequestResponse(new PermissionRequestResult
1338-
{
1339-
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
1340-
});
1341-
}
1342-
}
1343-
13441240
public async Task<UserInputRequestResponse> OnUserInputRequest(string sessionId, string question, List<string>? choices = null, bool? allowFreeform = null)
13451241
{
13461242
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
@@ -1473,12 +1369,6 @@ internal record ListSessionsRequest(
14731369
internal record ListSessionsResponse(
14741370
List<SessionMetadata> Sessions);
14751371

1476-
internal record ToolCallResponse(
1477-
ToolResultObject? Result);
1478-
1479-
internal record PermissionRequestResponse(
1480-
PermissionRequestResult Result);
1481-
14821372
internal record UserInputRequestResponse(
14831373
string Answer,
14841374
bool WasFreeform);
@@ -1578,14 +1468,12 @@ private static LogLevel MapLevel(TraceEventType eventType)
15781468
[JsonSerializable(typeof(HooksInvokeResponse))]
15791469
[JsonSerializable(typeof(ListSessionsRequest))]
15801470
[JsonSerializable(typeof(ListSessionsResponse))]
1581-
[JsonSerializable(typeof(PermissionRequestResponse))]
15821471
[JsonSerializable(typeof(PermissionRequestResult))]
15831472
[JsonSerializable(typeof(ProviderConfig))]
15841473
[JsonSerializable(typeof(ResumeSessionRequest))]
15851474
[JsonSerializable(typeof(ResumeSessionResponse))]
15861475
[JsonSerializable(typeof(SessionMetadata))]
15871476
[JsonSerializable(typeof(SystemMessageConfig))]
1588-
[JsonSerializable(typeof(ToolCallResponse))]
15891477
[JsonSerializable(typeof(ToolDefinition))]
15901478
[JsonSerializable(typeof(ToolResultAIContent))]
15911479
[JsonSerializable(typeof(ToolResultObject))]

dotnet/src/Generated/Rpc.cs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ internal class SessionPermissionsHandlePendingPermissionRequestRequest
508508
public string RequestId { get; set; } = string.Empty;
509509

510510
[JsonPropertyName("result")]
511-
public object Result { get; set; }
511+
public object Result { get; set; } = null!;
512512
}
513513

514514
[JsonConverter(typeof(JsonStringEnumConverter<SessionModeGetResultMode>))]
@@ -531,9 +531,9 @@ public class ServerRpc
531531
internal ServerRpc(JsonRpc rpc)
532532
{
533533
_rpc = rpc;
534-
Models = new ModelsApi(rpc);
535-
Tools = new ToolsApi(rpc);
536-
Account = new AccountApi(rpc);
534+
Models = new ServerModelsApi(rpc);
535+
Tools = new ServerToolsApi(rpc);
536+
Account = new ServerAccountApi(rpc);
537537
}
538538

539539
/// <summary>Calls "ping".</summary>
@@ -544,21 +544,21 @@ public async Task<PingResult> PingAsync(string? message = null, CancellationToke
544544
}
545545

546546
/// <summary>Models APIs.</summary>
547-
public ModelsApi Models { get; }
547+
public ServerModelsApi Models { get; }
548548

549549
/// <summary>Tools APIs.</summary>
550-
public ToolsApi Tools { get; }
550+
public ServerToolsApi Tools { get; }
551551

552552
/// <summary>Account APIs.</summary>
553-
public AccountApi Account { get; }
553+
public ServerAccountApi Account { get; }
554554
}
555555

556556
/// <summary>Server-scoped Models APIs.</summary>
557-
public class ModelsApi
557+
public class ServerModelsApi
558558
{
559559
private readonly JsonRpc _rpc;
560560

561-
internal ModelsApi(JsonRpc rpc)
561+
internal ServerModelsApi(JsonRpc rpc)
562562
{
563563
_rpc = rpc;
564564
}
@@ -571,11 +571,11 @@ public async Task<ModelsListResult> ListAsync(CancellationToken cancellationToke
571571
}
572572

573573
/// <summary>Server-scoped Tools APIs.</summary>
574-
public class ToolsApi
574+
public class ServerToolsApi
575575
{
576576
private readonly JsonRpc _rpc;
577577

578-
internal ToolsApi(JsonRpc rpc)
578+
internal ServerToolsApi(JsonRpc rpc)
579579
{
580580
_rpc = rpc;
581581
}
@@ -589,11 +589,11 @@ public async Task<ToolsListResult> ListAsync(string? model = null, CancellationT
589589
}
590590

591591
/// <summary>Server-scoped Account APIs.</summary>
592-
public class AccountApi
592+
public class ServerAccountApi
593593
{
594594
private readonly JsonRpc _rpc;
595595

596-
internal AccountApi(JsonRpc rpc)
596+
internal ServerAccountApi(JsonRpc rpc)
597597
{
598598
_rpc = rpc;
599599
}

dotnet/src/SdkProtocolVersion.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ internal static class SdkProtocolVersion
1111
/// <summary>
1212
/// The SDK protocol version.
1313
/// </summary>
14-
private const int Version = 2;
14+
private const int Version = 3;
1515

1616
/// <summary>
1717
/// Gets the SDK protocol version.

0 commit comments

Comments
 (0)