Skip to content

Commit 23edb58

Browse files
Add permission rejection test and use codegen'd types for result assertions
Add E2E test verifying one client can reject a permission and both clients see the denial in the permission.completed event. Regenerate SDK types from updated runtime schemas so permission.completed includes result.kind enum. Use type-narrowing filters instead of casts in test assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 23ecff3 commit 23edb58

5 files changed

Lines changed: 100 additions & 7 deletions

nodejs/src/generated/session-events.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2394,6 +2394,20 @@ export type SessionEvent =
23942394
* Request ID of the resolved permission request; clients should dismiss any UI for this request
23952395
*/
23962396
requestId: string;
2397+
/**
2398+
* The result of the permission request
2399+
*/
2400+
result: {
2401+
/**
2402+
* The outcome of the permission request
2403+
*/
2404+
kind:
2405+
| "approved"
2406+
| "denied-by-rules"
2407+
| "denied-no-approval-rule-and-could-not-request-from-user"
2408+
| "denied-interactively-by-user"
2409+
| "denied-by-content-exclusion-policy";
2410+
};
23972411
};
23982412
}
23992413
| {

nodejs/test/e2e/multi-client.test.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,70 @@ describe("Multi-client broadcast", async () => {
127127
expect(client1PermRequested.length).toBeGreaterThan(0);
128128
expect(client2PermRequested.length).toBeGreaterThan(0);
129129

130-
// Both clients should have seen permission.completed events
130+
// Both clients should have seen permission.completed events with approved result
131131
const client1PermCompleted = client1Events.filter(
132-
(e) => e.type === "permission.completed"
132+
(e): e is SessionEvent & { type: "permission.completed" } => e.type === "permission.completed"
133133
);
134134
const client2PermCompleted = client2Events.filter(
135-
(e) => e.type === "permission.completed"
135+
(e): e is SessionEvent & { type: "permission.completed" } => e.type === "permission.completed"
136136
);
137137
expect(client1PermCompleted.length).toBeGreaterThan(0);
138138
expect(client2PermCompleted.length).toBeGreaterThan(0);
139+
for (const event of [...client1PermCompleted, ...client2PermCompleted]) {
140+
expect(event.data.result.kind).toBe("approved");
141+
}
142+
143+
await session2.destroy();
144+
});
145+
146+
it("one client rejects permission and both see the result", async () => {
147+
// Client 1 creates a session and denies all permission requests
148+
const session1 = await client1.createSession({
149+
onPermissionRequest: () => ({ kind: "denied-interactively-by-user" as const }),
150+
});
151+
152+
// Client 2 resumes — its handler never resolves so only client 1's denial takes effect
153+
const session2 = await client2.resumeSession(session1.sessionId, {
154+
onPermissionRequest: () => new Promise(() => {}),
155+
});
156+
157+
const client1Events: SessionEvent[] = [];
158+
const client2Events: SessionEvent[] = [];
159+
160+
session1.on((event) => client1Events.push(event));
161+
session2.on((event) => client2Events.push(event));
162+
163+
// Ask the agent to write a file (requires permission)
164+
const { writeFile } = await import("fs/promises");
165+
const { join } = await import("path");
166+
const testFile = join(ctx.workDir, "protected.txt");
167+
await writeFile(testFile, "protected content");
168+
169+
await session1.sendAndWait({
170+
prompt: "Edit protected.txt and replace 'protected' with 'hacked'.",
171+
});
172+
173+
// Verify the file was NOT modified (permission was denied)
174+
const { readFile } = await import("fs/promises");
175+
const content = await readFile(testFile, "utf-8");
176+
expect(content).toBe("protected content");
177+
178+
// Both clients should have seen permission.requested and permission.completed
179+
expect(client1Events.filter((e) => e.type === "permission.requested").length).toBeGreaterThan(0);
180+
expect(client2Events.filter((e) => e.type === "permission.requested").length).toBeGreaterThan(0);
181+
182+
// Both clients should see the denial in the completed event
183+
const client1PermCompleted = client1Events.filter(
184+
(e): e is SessionEvent & { type: "permission.completed" } => e.type === "permission.completed"
185+
);
186+
const client2PermCompleted = client2Events.filter(
187+
(e): e is SessionEvent & { type: "permission.completed" } => e.type === "permission.completed"
188+
);
189+
expect(client1PermCompleted.length).toBeGreaterThan(0);
190+
expect(client2PermCompleted.length).toBeGreaterThan(0);
191+
for (const event of [...client1PermCompleted, ...client2PermCompleted]) {
192+
expect(event.data.result.kind).toBe("denied-interactively-by-user");
193+
}
139194

140195
await session2.destroy();
141196
});

test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ conversations:
4747
tool_call_id: toolcall_1
4848
content: MAGIC_hello_42
4949
- role: assistant
50-
content: "The magic_number tool with seed 'hello' returned: **MAGIC_hello_42**"
50+
content: "The magic number tool returned: **MAGIC_hello_42**"

test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ conversations:
77
- role: user
88
content: Create a file called hello.txt containing the text 'hello world'
99
- role: assistant
10-
content: I'll create the hello.txt file with "hello world" content for you.
10+
content: I'll create the hello.txt file with the text 'hello world' for you.
1111
- role: assistant
1212
tool_calls:
1313
- id: toolcall_0
@@ -28,7 +28,7 @@ conversations:
2828
- role: user
2929
content: Create a file called hello.txt containing the text 'hello world'
3030
- role: assistant
31-
content: I'll create the hello.txt file with "hello world" content for you.
31+
content: I'll create the hello.txt file with the text 'hello world' for you.
3232
tool_calls:
3333
- id: toolcall_0
3434
type: function
@@ -47,4 +47,4 @@ conversations:
4747
tool_call_id: toolcall_1
4848
content: Created file ${workdir}/hello.txt with 11 characters
4949
- role: assistant
50-
content: Done! I've created hello.txt containing "hello world" in your current directory.
50+
content: Done! I've created the file `hello.txt` containing the text 'hello world' in your current directory.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: Edit protected.txt and replace 'protected' with 'hacked'.
9+
- role: assistant
10+
content: I'll help you edit the file. Let me first view its current contents, then make the change.
11+
- role: assistant
12+
tool_calls:
13+
- id: toolcall_0
14+
type: function
15+
function:
16+
name: report_intent
17+
arguments: '{"intent":"Editing protected.txt file"}'
18+
- role: assistant
19+
tool_calls:
20+
- id: toolcall_1
21+
type: function
22+
function:
23+
name: view
24+
arguments: '{"path":"${workdir}/protected.txt"}'

0 commit comments

Comments
 (0)