Skip to content

Commit e280f87

Browse files
Add multi-client broadcast E2E tests for tools and permissions
Verify that when two SDK clients are connected to the same CLI process, both see external_tool.requested/completed and permission.requested/completed events. The permission test has client 1 manually approve while client 2 observes, confirming the broadcast model works across connections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 83937d6 commit e280f87

3 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import { describe, expect, it, afterAll } from "vitest";
6+
import { z } from "zod";
7+
import { CopilotClient, defineTool, approveAll } from "../../src/index.js";
8+
import type { SessionEvent } from "../../src/index.js";
9+
import { createSdkTestContext } from "./harness/sdkTestContext";
10+
11+
describe("Multi-client broadcast", async () => {
12+
// Use TCP mode so a second client can connect to the same CLI process
13+
const ctx = await createSdkTestContext({ useStdio: false });
14+
const client1 = ctx.copilotClient;
15+
16+
// Trigger connection so we can read the port
17+
const initSession = await client1.createSession({ onPermissionRequest: approveAll });
18+
await initSession.destroy();
19+
20+
const actualPort = (client1 as unknown as { actualPort: number }).actualPort;
21+
const client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` });
22+
23+
afterAll(async () => {
24+
await client2.stop();
25+
});
26+
27+
it("both clients see tool request and completion events", async () => {
28+
const tool = defineTool("magic_number", {
29+
description: "Returns a magic number",
30+
parameters: z.object({
31+
seed: z.string().describe("A seed value"),
32+
}),
33+
handler: ({ seed }) => `MAGIC_${seed}_42`,
34+
});
35+
36+
// Client 1 creates a session with a custom tool
37+
const session1 = await client1.createSession({
38+
onPermissionRequest: approveAll,
39+
tools: [tool],
40+
});
41+
42+
// Client 2 resumes the same session (separate TCP connection, own handlers)
43+
const session2 = await client2.resumeSession(session1.sessionId, {
44+
onPermissionRequest: approveAll,
45+
tools: [tool],
46+
});
47+
48+
// Track events seen by each client
49+
const client1Events: SessionEvent[] = [];
50+
const client2Events: SessionEvent[] = [];
51+
52+
session1.on((event) => client1Events.push(event));
53+
session2.on((event) => client2Events.push(event));
54+
55+
// Send a prompt that triggers the custom tool
56+
const response = await session1.sendAndWait({
57+
prompt: "Use the magic_number tool with seed 'hello' and tell me the result",
58+
});
59+
60+
// The response should contain the tool's output
61+
expect(response?.data.content).toContain("MAGIC_hello_42");
62+
63+
// Both clients should have seen the external_tool.requested event
64+
const client1ToolRequested = client1Events.filter(
65+
(e) => e.type === "external_tool.requested"
66+
);
67+
const client2ToolRequested = client2Events.filter(
68+
(e) => e.type === "external_tool.requested"
69+
);
70+
expect(client1ToolRequested.length).toBeGreaterThan(0);
71+
expect(client2ToolRequested.length).toBeGreaterThan(0);
72+
73+
// Both clients should have seen the external_tool.completed event
74+
const client1ToolCompleted = client1Events.filter(
75+
(e) => e.type === "external_tool.completed"
76+
);
77+
const client2ToolCompleted = client2Events.filter(
78+
(e) => e.type === "external_tool.completed"
79+
);
80+
expect(client1ToolCompleted.length).toBeGreaterThan(0);
81+
expect(client2ToolCompleted.length).toBeGreaterThan(0);
82+
83+
await session2.destroy();
84+
});
85+
86+
it("one client approves permission and both see the result", async () => {
87+
const client1PermissionRequests: unknown[] = [];
88+
89+
// Client 1 creates a session and manually approves permission requests
90+
const session1 = await client1.createSession({
91+
onPermissionRequest: (request) => {
92+
client1PermissionRequests.push(request);
93+
return { kind: "approved" as const };
94+
},
95+
});
96+
97+
// Client 2 resumes the same session — no permission handler needed,
98+
// it just observes the broadcast events
99+
const session2 = await client2.resumeSession(session1.sessionId, {
100+
onPermissionRequest: approveAll,
101+
});
102+
103+
// Track events seen by each client
104+
const client1Events: SessionEvent[] = [];
105+
const client2Events: SessionEvent[] = [];
106+
107+
session1.on((event) => client1Events.push(event));
108+
session2.on((event) => client2Events.push(event));
109+
110+
// Send a prompt that triggers a write operation (requires permission)
111+
const response = await session1.sendAndWait({
112+
prompt: "Create a file called hello.txt containing the text 'hello world'",
113+
});
114+
115+
expect(response?.data.content).toBeTruthy();
116+
117+
// Client 1 should have handled the permission request
118+
expect(client1PermissionRequests.length).toBeGreaterThan(0);
119+
120+
// Both clients should have seen permission.requested events
121+
const client1PermRequested = client1Events.filter(
122+
(e) => e.type === "permission.requested"
123+
);
124+
const client2PermRequested = client2Events.filter(
125+
(e) => e.type === "permission.requested"
126+
);
127+
expect(client1PermRequested.length).toBeGreaterThan(0);
128+
expect(client2PermRequested.length).toBeGreaterThan(0);
129+
130+
// Both clients should have seen permission.completed events
131+
const client1PermCompleted = client1Events.filter(
132+
(e) => e.type === "permission.completed"
133+
);
134+
const client2PermCompleted = client2Events.filter(
135+
(e) => e.type === "permission.completed"
136+
);
137+
expect(client1PermCompleted.length).toBeGreaterThan(0);
138+
expect(client2PermCompleted.length).toBeGreaterThan(0);
139+
140+
await session2.destroy();
141+
});
142+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: Use the magic_number tool with seed 'hello' and tell me the result
9+
- role: assistant
10+
content: I'll use the magic_number tool with the seed 'hello' for you.
11+
- role: assistant
12+
tool_calls:
13+
- id: toolcall_0
14+
type: function
15+
function:
16+
name: report_intent
17+
arguments: '{"intent":"Getting magic number"}'
18+
- role: assistant
19+
tool_calls:
20+
- id: toolcall_1
21+
type: function
22+
function:
23+
name: magic_number
24+
arguments: '{"seed":"hello"}'
25+
- messages:
26+
- role: system
27+
content: ${system}
28+
- role: user
29+
content: Use the magic_number tool with seed 'hello' and tell me the result
30+
- role: assistant
31+
content: I'll use the magic_number tool with the seed 'hello' for you.
32+
tool_calls:
33+
- id: toolcall_0
34+
type: function
35+
function:
36+
name: report_intent
37+
arguments: '{"intent":"Getting magic number"}'
38+
- id: toolcall_1
39+
type: function
40+
function:
41+
name: magic_number
42+
arguments: '{"seed":"hello"}'
43+
- role: tool
44+
tool_call_id: toolcall_0
45+
content: Intent logged
46+
- role: tool
47+
tool_call_id: toolcall_1
48+
content: MAGIC_hello_42
49+
- role: assistant
50+
content: "The magic_number tool with seed 'hello' returned: **MAGIC_hello_42**"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: Create a file called hello.txt containing the text 'hello world'
9+
- role: assistant
10+
content: I'll create the hello.txt file with "hello world" content for you.
11+
- role: assistant
12+
tool_calls:
13+
- id: toolcall_0
14+
type: function
15+
function:
16+
name: report_intent
17+
arguments: '{"intent":"Creating hello.txt file"}'
18+
- role: assistant
19+
tool_calls:
20+
- id: toolcall_1
21+
type: function
22+
function:
23+
name: create
24+
arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}'
25+
- messages:
26+
- role: system
27+
content: ${system}
28+
- role: user
29+
content: Create a file called hello.txt containing the text 'hello world'
30+
- role: assistant
31+
content: I'll create the hello.txt file with "hello world" content for you.
32+
tool_calls:
33+
- id: toolcall_0
34+
type: function
35+
function:
36+
name: report_intent
37+
arguments: '{"intent":"Creating hello.txt file"}'
38+
- id: toolcall_1
39+
type: function
40+
function:
41+
name: create
42+
arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}'
43+
- role: tool
44+
tool_call_id: toolcall_0
45+
content: Intent logged
46+
- role: tool
47+
tool_call_id: toolcall_1
48+
content: Created file ${workdir}/hello.txt with 11 characters
49+
- role: assistant
50+
content: Done! I've created hello.txt containing "hello world" in your current directory.

0 commit comments

Comments
 (0)