Skip to content

Commit f6f931e

Browse files
Python SDK: migrate to protocol v3 broadcast model
- Bump protocol version 2→3 - Remove tool.call/permission.request RPC handlers from client.py - Add broadcast event handling in session.py (_handle_broadcast_event, _execute_tool_and_respond, _execute_permission_and_respond) - Fix Python codegen naming collision (Server prefix for server-scoped APIs) - Add multi-client E2E tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 78ddebb commit f6f931e

8 files changed

Lines changed: 710 additions & 208 deletions

File tree

dotnet/test/MultiClientTests.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,14 +248,20 @@ public async Task Two_Clients_Register_Different_Tools_And_Agent_Uses_Both()
248248
Tools = [toolB],
249249
});
250250

251-
var response = await session1.SendAndWaitAsync(new MessageOptions
251+
// Send prompts sequentially to avoid nondeterministic tool_call ordering
252+
var response1 = await session1.SendAndWaitAsync(new MessageOptions
252253
{
253-
Prompt = "Use the city_lookup tool with countryCode 'US' and the currency_lookup tool with countryCode 'US'. Tell me both results.",
254+
Prompt = "Use the city_lookup tool with countryCode 'US' and tell me the result.",
254255
});
256+
Assert.NotNull(response1);
257+
Assert.Contains("CITY_FOR_US", response1!.Data.Content ?? string.Empty);
255258

256-
Assert.NotNull(response);
257-
Assert.Contains("CITY_FOR_US", response!.Data.Content ?? string.Empty);
258-
Assert.Contains("CURRENCY_FOR_US", response!.Data.Content ?? string.Empty);
259+
var response2 = await session1.SendAndWaitAsync(new MessageOptions
260+
{
261+
Prompt = "Now use the currency_lookup tool with countryCode 'US' and tell me the result.",
262+
});
263+
Assert.NotNull(response2);
264+
Assert.Contains("CURRENCY_FOR_US", response2!.Data.Content ?? string.Empty);
259265

260266
await session2.DisposeAsync();
261267

@@ -284,14 +290,20 @@ public async Task Disconnecting_Client_Removes_Its_Tools()
284290
Tools = [toolB],
285291
});
286292

287-
// Verify both tools work before disconnect
288-
var bothResponse = await session1.SendAndWaitAsync(new MessageOptions
293+
// Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering)
294+
var stableResponse = await session1.SendAndWaitAsync(new MessageOptions
295+
{
296+
Prompt = "Use the stable_tool with input 'test1' and tell me the result.",
297+
});
298+
Assert.NotNull(stableResponse);
299+
Assert.Contains("STABLE_test1", stableResponse!.Data.Content ?? string.Empty);
300+
301+
var ephemeralResponse = await session1.SendAndWaitAsync(new MessageOptions
289302
{
290-
Prompt = "Use the stable_tool with input 'test1' and the ephemeral_tool with input 'test2'. Tell me both results.",
303+
Prompt = "Use the ephemeral_tool with input 'test2' and tell me the result.",
291304
});
292-
Assert.NotNull(bothResponse);
293-
Assert.Contains("STABLE_test1", bothResponse!.Data.Content ?? string.Empty);
294-
Assert.Contains("EPHEMERAL_test2", bothResponse!.Data.Content ?? string.Empty);
305+
Assert.NotNull(ephemeralResponse);
306+
Assert.Contains("EPHEMERAL_test2", ephemeralResponse!.Data.Content ?? string.Empty);
295307

296308
// Disconnect client 2
297309
await Client2.ForceStopAsync();

go/internal/e2e/multi_client_test.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -365,22 +365,31 @@ func TestMultiClient(t *testing.T) {
365365
t.Fatalf("Failed to resume session: %v", err)
366366
}
367367

368-
// Send a prompt that requires both tools
369-
response, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
370-
Prompt: "Use the city_lookup tool with countryCode 'US' and the currency_lookup tool with countryCode 'US'. Tell me both results.",
368+
// Send prompts sequentially to avoid nondeterministic tool_call ordering
369+
response1, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
370+
Prompt: "Use the city_lookup tool with countryCode 'US' and tell me the result.",
371371
})
372372
if err != nil {
373373
t.Fatalf("Failed to send message: %v", err)
374374
}
375-
376-
if response == nil || response.Data.Content == nil {
375+
if response1 == nil || response1.Data.Content == nil {
377376
t.Fatalf("Expected response with content")
378377
}
379-
if !strings.Contains(*response.Data.Content, "CITY_FOR_US") {
380-
t.Errorf("Expected response to contain 'CITY_FOR_US', got '%s'", *response.Data.Content)
378+
if !strings.Contains(*response1.Data.Content, "CITY_FOR_US") {
379+
t.Errorf("Expected response to contain 'CITY_FOR_US', got '%s'", *response1.Data.Content)
380+
}
381+
382+
response2, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
383+
Prompt: "Now use the currency_lookup tool with countryCode 'US' and tell me the result.",
384+
})
385+
if err != nil {
386+
t.Fatalf("Failed to send message: %v", err)
387+
}
388+
if response2 == nil || response2.Data.Content == nil {
389+
t.Fatalf("Expected response with content")
381390
}
382-
if !strings.Contains(*response.Data.Content, "CURRENCY_FOR_US") {
383-
t.Errorf("Expected response to contain 'CURRENCY_FOR_US', got '%s'", *response.Data.Content)
391+
if !strings.Contains(*response2.Data.Content, "CURRENCY_FOR_US") {
392+
t.Errorf("Expected response to contain 'CURRENCY_FOR_US', got '%s'", *response2.Data.Content)
384393
}
385394

386395
session2.Disconnect()
@@ -421,21 +430,31 @@ func TestMultiClient(t *testing.T) {
421430
t.Fatalf("Failed to resume session: %v", err)
422431
}
423432

424-
// Verify both tools work before disconnect
425-
bothResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
426-
Prompt: "Use the stable_tool with input 'test1' and the ephemeral_tool with input 'test2'. Tell me both results.",
433+
// Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering)
434+
stableResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
435+
Prompt: "Use the stable_tool with input 'test1' and tell me the result.",
427436
})
428437
if err != nil {
429438
t.Fatalf("Failed to send message: %v", err)
430439
}
431-
if bothResponse == nil || bothResponse.Data.Content == nil {
440+
if stableResponse == nil || stableResponse.Data.Content == nil {
432441
t.Fatalf("Expected response with content")
433442
}
434-
if !strings.Contains(*bothResponse.Data.Content, "STABLE_test1") {
435-
t.Errorf("Expected response to contain 'STABLE_test1', got '%s'", *bothResponse.Data.Content)
443+
if !strings.Contains(*stableResponse.Data.Content, "STABLE_test1") {
444+
t.Errorf("Expected response to contain 'STABLE_test1', got '%s'", *stableResponse.Data.Content)
445+
}
446+
447+
ephemeralResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{
448+
Prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.",
449+
})
450+
if err != nil {
451+
t.Fatalf("Failed to send message: %v", err)
452+
}
453+
if ephemeralResponse == nil || ephemeralResponse.Data.Content == nil {
454+
t.Fatalf("Expected response with content")
436455
}
437-
if !strings.Contains(*bothResponse.Data.Content, "EPHEMERAL_test2") {
438-
t.Errorf("Expected response to contain 'EPHEMERAL_test2', got '%s'", *bothResponse.Data.Content)
456+
if !strings.Contains(*ephemeralResponse.Data.Content, "EPHEMERAL_test2") {
457+
t.Errorf("Expected response to contain 'EPHEMERAL_test2', got '%s'", *ephemeralResponse.Data.Content)
439458
}
440459

441460
// Disconnect client 2 without destroying the shared session

python/copilot/client.py

Lines changed: 16 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,12 @@
1313
"""
1414

1515
import asyncio
16-
import inspect
1716
import os
1817
import re
1918
import subprocess
2019
import sys
2120
import threading
2221
from collections.abc import Callable
23-
from dataclasses import asdict, is_dataclass
2422
from pathlib import Path
2523
from typing import Any, cast
2624

@@ -46,9 +44,6 @@
4644
SessionListFilter,
4745
SessionMetadata,
4846
StopError,
49-
ToolHandler,
50-
ToolInvocation,
51-
ToolResult,
5247
)
5348

5449

@@ -219,6 +214,16 @@ def rpc(self) -> ServerRpc:
219214
raise RuntimeError("Client is not connected. Call start() first.")
220215
return self._rpc
221216

217+
@property
218+
def actual_port(self) -> int | None:
219+
"""The actual TCP port the CLI server is listening on, if using TCP transport.
220+
221+
Useful for multi-client scenarios where a second client needs to connect
222+
to the same server. Only available after :meth:`start` completes and
223+
only when not using stdio transport.
224+
"""
225+
return self._actual_port
226+
222227
def _parse_cli_url(self, url: str) -> tuple[str, int]:
223228
"""
224229
Parse CLI URL into host and port.
@@ -1354,8 +1359,10 @@ def handle_notification(method: str, params: dict):
13541359
self._dispatch_lifecycle_event(lifecycle_event)
13551360

13561361
self._client.set_notification_handler(handle_notification)
1357-
self._client.set_request_handler("tool.call", self._handle_tool_call_request)
1358-
self._client.set_request_handler("permission.request", self._handle_permission_request)
1362+
# Protocol v3: tool.call and permission.request RPC handlers removed.
1363+
# Tool calls and permission requests are now broadcast as session events
1364+
# (external_tool.requested, permission.requested) and handled in
1365+
# Session._handle_broadcast_event.
13591366
self._client.set_request_handler("userInput.request", self._handle_user_input_request)
13601367
self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke)
13611368

@@ -1435,50 +1442,15 @@ def handle_notification(method: str, params: dict):
14351442
self._dispatch_lifecycle_event(lifecycle_event)
14361443

14371444
self._client.set_notification_handler(handle_notification)
1438-
self._client.set_request_handler("tool.call", self._handle_tool_call_request)
1439-
self._client.set_request_handler("permission.request", self._handle_permission_request)
1445+
# Protocol v3: tool.call and permission.request RPC handlers removed.
1446+
# See _connect_via_stdio for details.
14401447
self._client.set_request_handler("userInput.request", self._handle_user_input_request)
14411448
self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke)
14421449

14431450
# Start listening for messages
14441451
loop = asyncio.get_running_loop()
14451452
self._client.start(loop)
14461453

1447-
async def _handle_permission_request(self, params: dict) -> dict:
1448-
"""
1449-
Handle a permission request from the CLI server.
1450-
1451-
Args:
1452-
params: The permission request parameters from the server.
1453-
1454-
Returns:
1455-
A dict containing the permission decision result.
1456-
1457-
Raises:
1458-
ValueError: If the request payload is invalid.
1459-
"""
1460-
session_id = params.get("sessionId")
1461-
permission_request = params.get("permissionRequest")
1462-
1463-
if not session_id or not permission_request:
1464-
raise ValueError("invalid permission request payload")
1465-
1466-
with self._sessions_lock:
1467-
session = self._sessions.get(session_id)
1468-
if not session:
1469-
raise ValueError(f"unknown session {session_id}")
1470-
1471-
try:
1472-
result = await session._handle_permission_request(permission_request)
1473-
return {"result": result}
1474-
except Exception: # pylint: disable=broad-except
1475-
# If permission handler fails, deny the permission
1476-
return {
1477-
"result": {
1478-
"kind": "denied-no-approval-rule-and-could-not-request-from-user",
1479-
}
1480-
}
1481-
14821454
async def _handle_user_input_request(self, params: dict) -> dict:
14831455
"""
14841456
Handle a user input request from the CLI server.
@@ -1533,129 +1505,3 @@ async def _handle_hooks_invoke(self, params: dict) -> dict:
15331505

15341506
output = await session._handle_hooks_invoke(hook_type, input_data)
15351507
return {"output": output}
1536-
1537-
async def _handle_tool_call_request(self, params: dict) -> dict:
1538-
"""
1539-
Handle a tool call request from the CLI server.
1540-
1541-
Args:
1542-
params: The tool call parameters from the server.
1543-
1544-
Returns:
1545-
A dict containing the tool execution result.
1546-
1547-
Raises:
1548-
ValueError: If the request payload is invalid or session is unknown.
1549-
"""
1550-
session_id = params.get("sessionId")
1551-
tool_call_id = params.get("toolCallId")
1552-
tool_name = params.get("toolName")
1553-
1554-
if not session_id or not tool_call_id or not tool_name:
1555-
raise ValueError("invalid tool call payload")
1556-
1557-
with self._sessions_lock:
1558-
session = self._sessions.get(session_id)
1559-
if not session:
1560-
raise ValueError(f"unknown session {session_id}")
1561-
1562-
handler = session._get_tool_handler(tool_name)
1563-
if not handler:
1564-
return {"result": self._build_unsupported_tool_result(tool_name)}
1565-
1566-
arguments = params.get("arguments")
1567-
result = await self._execute_tool_call(
1568-
session_id,
1569-
tool_call_id,
1570-
tool_name,
1571-
arguments,
1572-
handler,
1573-
)
1574-
1575-
return {"result": result}
1576-
1577-
async def _execute_tool_call(
1578-
self,
1579-
session_id: str,
1580-
tool_call_id: str,
1581-
tool_name: str,
1582-
arguments: Any,
1583-
handler: ToolHandler,
1584-
) -> ToolResult:
1585-
"""
1586-
Execute a tool call with the given handler.
1587-
1588-
Args:
1589-
session_id: The session ID making the tool call.
1590-
tool_call_id: The unique ID for this tool call.
1591-
tool_name: The name of the tool being called.
1592-
arguments: The arguments to pass to the tool handler.
1593-
handler: The tool handler function to execute.
1594-
1595-
Returns:
1596-
A ToolResult containing the execution result or error.
1597-
"""
1598-
invocation: ToolInvocation = {
1599-
"session_id": session_id,
1600-
"tool_call_id": tool_call_id,
1601-
"tool_name": tool_name,
1602-
"arguments": arguments,
1603-
}
1604-
1605-
try:
1606-
result = handler(invocation)
1607-
if inspect.isawaitable(result):
1608-
result = await result
1609-
except Exception as exc: # pylint: disable=broad-except
1610-
# Don't expose detailed error information to the LLM for security reasons.
1611-
# The actual error is stored in the 'error' field for debugging.
1612-
result = ToolResult(
1613-
textResultForLlm="Invoking this tool produced an error. "
1614-
"Detailed information is not available.",
1615-
resultType="failure",
1616-
error=str(exc),
1617-
toolTelemetry={},
1618-
)
1619-
1620-
if result is None:
1621-
result = ToolResult(
1622-
textResultForLlm="Tool returned no result.",
1623-
resultType="failure",
1624-
error="tool returned no result",
1625-
toolTelemetry={},
1626-
)
1627-
1628-
return self._normalize_tool_result(cast(ToolResult, result))
1629-
1630-
def _normalize_tool_result(self, result: ToolResult) -> ToolResult:
1631-
"""
1632-
Normalize a tool result for transmission.
1633-
1634-
Converts dataclass instances to dictionaries for JSON serialization.
1635-
1636-
Args:
1637-
result: The tool result to normalize.
1638-
1639-
Returns:
1640-
The normalized tool result.
1641-
"""
1642-
if is_dataclass(result) and not isinstance(result, type):
1643-
return asdict(result) # type: ignore[arg-type]
1644-
return result
1645-
1646-
def _build_unsupported_tool_result(self, tool_name: str) -> ToolResult:
1647-
"""
1648-
Build a failure result for an unsupported tool.
1649-
1650-
Args:
1651-
tool_name: The name of the unsupported tool.
1652-
1653-
Returns:
1654-
A ToolResult indicating the tool is not supported.
1655-
"""
1656-
return ToolResult(
1657-
textResultForLlm=f"Tool '{tool_name}' is not supported.",
1658-
resultType="failure",
1659-
error=f"tool '{tool_name}' not supported",
1660-
toolTelemetry={},
1661-
)

0 commit comments

Comments
 (0)