Skip to content

Commit f7fe4c1

Browse files
patnikoCopilot
andcommitted
feat: add protocol v3 support with backwards compat for v2
Add version negotiation so the SDK accepts servers reporting protocol version 2 or 3. This fixes the incompatibility with CLI >= 0.0.423 / 1.0.x that was introduced by the broadcast model migration (runtime PR #4243). Changes across all four SDKs (Node, Python, Go, .NET): - sdk-protocol-version.json now specifies version range (min: 2, max: 3) - Codegen emits both SDK_PROTOCOL_VERSION and MIN_SDK_PROTOCOL_VERSION - verifyProtocolVersion accepts a range instead of strict equality - Negotiated version stored on client instance - Old tool.call / permission.request RPC handlers kept for v2 compat - New v3 broadcast event interception (external_tool.requested, permission.requested) guarded by negotiatedProtocolVersion >= 3 - v3 callbacks via session.tools.handlePendingToolCall and session.permissions.handlePendingPermissionRequest RPC methods Fixes #701 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4e1499d commit f7fe4c1

12 files changed

Lines changed: 740 additions & 79 deletions

dotnet/src/Client.cs

Lines changed: 194 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
6767
private readonly Dictionary<string, List<Action<SessionLifecycleEvent>>> _typedLifecycleHandlers = [];
6868
private readonly object _lifecycleHandlersLock = new();
6969
private ServerRpc? _rpc;
70+
private JsonRpc? _jsonRpc;
71+
private int _negotiatedProtocolVersion;
7072

7173
/// <summary>
7274
/// Gets the typed RPC client for server-scoped methods (no session required).
@@ -318,6 +320,8 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
318320

319321
// Clear RPC and models cache
320322
_rpc = null;
323+
_jsonRpc = null;
324+
_negotiatedProtocolVersion = 0;
321325
_modelsCache = null;
322326

323327
if (ctx.NetworkStream is not null)
@@ -915,27 +919,31 @@ private Task<Connection> EnsureConnectedAsync(CancellationToken cancellationToke
915919
return (Task<Connection>)StartAsync(cancellationToken);
916920
}
917921

918-
private static async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
922+
private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
919923
{
920-
var expectedVersion = SdkProtocolVersion.GetVersion();
924+
var maxVersion = SdkProtocolVersion.GetVersion();
925+
var minVersion = SdkProtocolVersion.GetMinVersion();
921926
var pingResponse = await InvokeRpcAsync<PingResponse>(
922927
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
923928

924929
if (!pingResponse.ProtocolVersion.HasValue)
925930
{
926931
throw new InvalidOperationException(
927-
$"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
932+
$"SDK protocol version mismatch: SDK supports versions {minVersion}-{maxVersion}, " +
928933
$"but server does not report a protocol version. " +
929934
$"Please update your server to ensure compatibility.");
930935
}
931936

932-
if (pingResponse.ProtocolVersion.Value != expectedVersion)
937+
var serverVersion = pingResponse.ProtocolVersion.Value;
938+
if (serverVersion < minVersion || serverVersion > maxVersion)
933939
{
934940
throw new InvalidOperationException(
935-
$"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
936-
$"but server reports version {pingResponse.ProtocolVersion.Value}. " +
941+
$"SDK protocol version mismatch: SDK supports versions {minVersion}-{maxVersion}, " +
942+
$"but server reports version {serverVersion}. " +
937943
$"Please update your SDK or server to ensure compatibility.");
938944
}
945+
946+
_negotiatedProtocolVersion = serverVersion;
939947
}
940948

941949
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
@@ -1135,6 +1143,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
11351143
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
11361144
rpc.StartListening();
11371145

1146+
_jsonRpc = rpc;
11381147
_rpc = new ServerRpc(rpc);
11391148

11401149
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer);
@@ -1173,6 +1182,145 @@ private static JsonSerializerOptions CreateSerializerOptions()
11731182
return _sessions.TryGetValue(sessionId, out var session) ? session : null;
11741183
}
11751184

1185+
private async Task HandleExternalToolRequestedAsync(string sessionId, JsonElement eventJson)
1186+
{
1187+
string? requestId = null;
1188+
try
1189+
{
1190+
if (!eventJson.TryGetProperty("data", out var data)) return;
1191+
1192+
requestId = data.TryGetProperty("requestId", out var rid) ? rid.GetString() : null;
1193+
var toolCallId = data.TryGetProperty("toolCallId", out var tcid) ? tcid.GetString() : null;
1194+
var toolName = data.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
1195+
1196+
if (requestId == null || toolName == null) return;
1197+
1198+
var session = GetSession(sessionId);
1199+
if (session == null) return;
1200+
1201+
ToolResultObject resultObj;
1202+
if (session.GetTool(toolName) is not { } tool)
1203+
{
1204+
resultObj = new ToolResultObject
1205+
{
1206+
TextResultForLlm = $"Tool '{toolName}' is not supported.",
1207+
ResultType = "failure",
1208+
Error = $"tool '{toolName}' not supported"
1209+
};
1210+
}
1211+
else
1212+
{
1213+
try
1214+
{
1215+
var arguments = data.TryGetProperty("arguments", out var args) ? (object?)args : null;
1216+
1217+
var invocation = new ToolInvocation
1218+
{
1219+
SessionId = sessionId,
1220+
ToolCallId = toolCallId ?? "",
1221+
ToolName = toolName,
1222+
Arguments = arguments
1223+
};
1224+
1225+
var aiFunctionArgs = new AIFunctionArguments
1226+
{
1227+
Context = new Dictionary<object, object?>
1228+
{
1229+
[typeof(ToolInvocation)] = invocation
1230+
}
1231+
};
1232+
1233+
if (arguments is JsonElement { ValueKind: JsonValueKind.Object } incomingJsonArgs)
1234+
{
1235+
foreach (var prop in incomingJsonArgs.EnumerateObject())
1236+
{
1237+
aiFunctionArgs[prop.Name] = prop.Value;
1238+
}
1239+
}
1240+
1241+
var result = await tool.InvokeAsync(aiFunctionArgs);
1242+
1243+
resultObj = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject
1244+
{
1245+
ResultType = "success",
1246+
TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je
1247+
? je.GetString()!
1248+
: JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))),
1249+
};
1250+
}
1251+
catch (Exception ex)
1252+
{
1253+
resultObj = new ToolResultObject
1254+
{
1255+
TextResultForLlm = "Invoking this tool produced an error. Detailed information is not available.",
1256+
ResultType = "failure",
1257+
Error = ex.Message
1258+
};
1259+
}
1260+
}
1261+
1262+
if (_jsonRpc is { } rpc)
1263+
{
1264+
await InvokeRpcAsync<JsonElement>(rpc, "session.tools.handlePendingToolCall",
1265+
[new HandlePendingToolCallRequest(sessionId, requestId, Result: resultObj)], CancellationToken.None);
1266+
}
1267+
}
1268+
catch (Exception)
1269+
{
1270+
try
1271+
{
1272+
if (_jsonRpc is { } rpc && requestId != null)
1273+
{
1274+
await InvokeRpcAsync<JsonElement>(rpc, "session.tools.handlePendingToolCall",
1275+
[new HandlePendingToolCallRequest(sessionId, requestId, Error: "Internal error handling tool call")],
1276+
CancellationToken.None);
1277+
}
1278+
}
1279+
catch { /* Connection may be closed */ }
1280+
}
1281+
}
1282+
1283+
private async Task HandlePermissionRequestedEventAsync(string sessionId, JsonElement eventJson)
1284+
{
1285+
string? requestId = null;
1286+
try
1287+
{
1288+
if (!eventJson.TryGetProperty("data", out var data)) return;
1289+
1290+
requestId = data.TryGetProperty("requestId", out var rid) ? rid.GetString() : null;
1291+
if (requestId == null) return;
1292+
1293+
var session = GetSession(sessionId);
1294+
if (session == null) return;
1295+
1296+
if (!data.TryGetProperty("permissionRequest", out var permissionRequest)) return;
1297+
1298+
var result = await session.HandlePermissionRequestAsync(permissionRequest);
1299+
1300+
if (_jsonRpc is { } rpc)
1301+
{
1302+
await InvokeRpcAsync<JsonElement>(rpc, "session.permissions.handlePendingPermissionRequest",
1303+
[new HandlePendingPermissionRequestRequest(sessionId, requestId, result)], CancellationToken.None);
1304+
}
1305+
}
1306+
catch (Exception)
1307+
{
1308+
try
1309+
{
1310+
if (_jsonRpc is { } rpc && requestId != null)
1311+
{
1312+
var deniedResult = new PermissionRequestResult
1313+
{
1314+
Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser
1315+
};
1316+
await InvokeRpcAsync<JsonElement>(rpc, "session.permissions.handlePendingPermissionRequest",
1317+
[new HandlePendingPermissionRequestRequest(sessionId, requestId, deniedResult)], CancellationToken.None);
1318+
}
1319+
}
1320+
catch { /* Connection may be closed */ }
1321+
}
1322+
}
1323+
11761324
/// <summary>
11771325
/// Disposes the <see cref="CopilotClient"/> synchronously.
11781326
/// </summary>
@@ -1202,15 +1350,41 @@ private class RpcHandler(CopilotClient client)
12021350
{
12031351
public void OnSessionEvent(string sessionId, JsonElement? @event)
12041352
{
1353+
if (@event == null) return;
1354+
1355+
// Extract event type for v3 broadcast handling
1356+
string? eventType = null;
1357+
if (@event.Value.TryGetProperty("type", out var typeProp))
1358+
{
1359+
eventType = typeProp.GetString();
1360+
}
1361+
1362+
// external_tool.requested is not in the typed schema; intercept on v3, always skip deserialization
1363+
if (eventType == "external_tool.requested")
1364+
{
1365+
if (client._negotiatedProtocolVersion >= 3)
1366+
{
1367+
_ = Task.Run(() => client.HandleExternalToolRequestedAsync(sessionId, @event.Value));
1368+
}
1369+
return;
1370+
}
1371+
1372+
// Normal typed event dispatch
12051373
var session = client.GetSession(sessionId);
1206-
if (session != null && @event != null)
1374+
if (session != null)
12071375
{
12081376
var evt = SessionEvent.FromJson(@event.Value.GetRawText());
12091377
if (evt != null)
12101378
{
12111379
session.DispatchEvent(evt);
12121380
}
12131381
}
1382+
1383+
// v3: permission.requested - handle via RPC callback in addition to event dispatch
1384+
if (client._negotiatedProtocolVersion >= 3 && eventType == "permission.requested")
1385+
{
1386+
_ = Task.Run(() => client.HandlePermissionRequestedEventAsync(sessionId, @event.Value));
1387+
}
12141388
}
12151389

12161390
public void OnSessionLifecycle(string type, string sessionId, JsonElement? metadata)
@@ -1486,6 +1660,17 @@ internal record UserInputRequestResponse(
14861660
internal record HooksInvokeResponse(
14871661
object? Output);
14881662

1663+
internal record HandlePendingToolCallRequest(
1664+
string SessionId,
1665+
string RequestId,
1666+
ToolResultObject? Result = null,
1667+
string? Error = null);
1668+
1669+
internal record HandlePendingPermissionRequestRequest(
1670+
string SessionId,
1671+
string RequestId,
1672+
PermissionRequestResult Result);
1673+
14891674
/// <summary>Trace source that forwards all logs to the ILogger.</summary>
14901675
internal sealed class LoggerTraceSource : TraceSource
14911676
{
@@ -1575,6 +1760,8 @@ private static LogLevel MapLevel(TraceEventType eventType)
15751760
[JsonSerializable(typeof(DeleteSessionRequest))]
15761761
[JsonSerializable(typeof(DeleteSessionResponse))]
15771762
[JsonSerializable(typeof(GetLastSessionIdResponse))]
1763+
[JsonSerializable(typeof(HandlePendingPermissionRequestRequest))]
1764+
[JsonSerializable(typeof(HandlePendingToolCallRequest))]
15781765
[JsonSerializable(typeof(HooksInvokeResponse))]
15791766
[JsonSerializable(typeof(ListSessionsRequest))]
15801767
[JsonSerializable(typeof(ListSessionsResponse))]

dotnet/src/SdkProtocolVersion.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,23 @@ namespace GitHub.Copilot.SDK;
99
internal static class SdkProtocolVersion
1010
{
1111
/// <summary>
12-
/// The SDK protocol version.
12+
/// The maximum SDK protocol version supported.
1313
/// </summary>
14-
private const int Version = 2;
14+
private const int Version = 3;
1515

1616
/// <summary>
17-
/// Gets the SDK protocol version.
17+
/// The minimum SDK protocol version supported.
18+
/// Servers reporting a version in [Min, Max] are considered compatible.
1819
/// </summary>
19-
public static int GetVersion()
20-
{
21-
return Version;
22-
}
20+
private const int MinVersion = 2;
21+
22+
/// <summary>
23+
/// Gets the SDK protocol version (maximum supported).
24+
/// </summary>
25+
public static int GetVersion() => Version;
26+
27+
/// <summary>
28+
/// Gets the minimum SDK protocol version supported.
29+
/// </summary>
30+
public static int GetMinVersion() => MinVersion;
2331
}

0 commit comments

Comments
 (0)