|
5 | 5 | import { readFile, writeFile } from "fs/promises"; |
6 | 6 | import { join } from "path"; |
7 | 7 | import { describe, expect, it } from "vitest"; |
| 8 | +import { z } from "zod"; |
8 | 9 | import type { |
| 10 | + CopilotClient, |
| 11 | + PermissionRequest, |
9 | 12 | PreToolUseHookInput, |
10 | 13 | PreToolUseHookOutput, |
11 | 14 | PostToolUseHookInput, |
12 | 15 | PostToolUseHookOutput, |
13 | 16 | } from "../../src/index.js"; |
14 | | -import { approveAll } from "../../src/index.js"; |
15 | | -import { createSdkTestContext } from "./harness/sdkTestContext.js"; |
| 17 | +import { approveAll, defineTool, RuntimeConnection } from "../../src/index.js"; |
| 18 | +import { |
| 19 | + createSdkTestContext, |
| 20 | + getLegacyCliPathForTests, |
| 21 | + isInProcessTransport, |
| 22 | +} from "./harness/sdkTestContext.js"; |
16 | 23 |
|
17 | 24 | describe("Session hooks", async () => { |
18 | | - const { copilotClient: client, workDir } = await createSdkTestContext(); |
| 25 | + const ctx = await createSdkTestContext(); |
| 26 | + const { copilotClient: client, workDir } = ctx; |
19 | 27 |
|
20 | 28 | it("should invoke preToolUse hook when model runs a tool", async () => { |
21 | 29 | const preToolUseInputs: PreToolUseHookInput[] = []; |
@@ -161,4 +169,78 @@ describe("Session hooks", async () => { |
161 | 169 |
|
162 | 170 | await session.disconnect(); |
163 | 171 | }); |
| 172 | + |
| 173 | + // Disconnecting the last owner tears the session down, so each resume must register |
| 174 | + // the SDK's hooks with the freshly restored hook service. |
| 175 | + async function expectPreToolUseAfterResume(target: CopilotClient): Promise<void> { |
| 176 | + const preToolUseInputs: PreToolUseHookInput[] = []; |
| 177 | + const permissionRequests: PermissionRequest[] = []; |
| 178 | + const sessionOptions = () => ({ |
| 179 | + tools: [ |
| 180 | + defineTool("encrypt_string", { |
| 181 | + description: "Encrypts a string", |
| 182 | + parameters: z.object({ |
| 183 | + input: z.string().describe("String to encrypt"), |
| 184 | + }), |
| 185 | + handler: ({ input }: { input: string }) => input.toUpperCase(), |
| 186 | + }), |
| 187 | + ], |
| 188 | + // Records rather than denies so a regression surfaces as a clear assertion |
| 189 | + // failure instead of a model-dependent denial transcript. |
| 190 | + onPermissionRequest: (request: PermissionRequest) => { |
| 191 | + permissionRequests.push(request); |
| 192 | + return { kind: "approve-once" } as const; |
| 193 | + }, |
| 194 | + hooks: { |
| 195 | + onPreToolUse: async (input: PreToolUseHookInput) => { |
| 196 | + preToolUseInputs.push(input); |
| 197 | + return { permissionDecision: "allow" } as PreToolUseHookOutput; |
| 198 | + }, |
| 199 | + }, |
| 200 | + }); |
| 201 | + |
| 202 | + let sessionId: string; |
| 203 | + { |
| 204 | + await using session1 = await target.createSession(sessionOptions()); |
| 205 | + sessionId = session1.sessionId; |
| 206 | + await session1.sendAndWait({ |
| 207 | + prompt: "Use encrypt_string to encrypt this string: Hello", |
| 208 | + }); |
| 209 | + expect(preToolUseInputs.map((input) => input.toolName)).toEqual(["encrypt_string"]); |
| 210 | + } |
| 211 | + |
| 212 | + await using session2 = await target.resumeSession(sessionId, sessionOptions()); |
| 213 | + const answer = await session2.sendAndWait({ |
| 214 | + prompt: "Use encrypt_string to encrypt this string: World", |
| 215 | + }); |
| 216 | + |
| 217 | + expect(preToolUseInputs.map((input) => input.toolName)).toEqual([ |
| 218 | + "encrypt_string", |
| 219 | + "encrypt_string", |
| 220 | + ]); |
| 221 | + expect(permissionRequests).toEqual([]); |
| 222 | + // Validate the final assistant response arrived (guards against truncated captures) |
| 223 | + expect(answer?.data.content).toContain("WORLD"); |
| 224 | + } |
| 225 | + |
| 226 | + it("should invoke preToolUse hook for a custom tool after disconnect and resume", async () => { |
| 227 | + await expectPreToolUseAfterResume(client); |
| 228 | + }); |
| 229 | + |
| 230 | + // The legacy JavaScript CLI host tears sessions down through a separate path. |
| 231 | + it.skipIf(isInProcessTransport)( |
| 232 | + "should invoke preToolUse hook for a custom tool after disconnect and resume on the legacy CLI", |
| 233 | + async () => { |
| 234 | + const legacyClient = ctx.createClient({ |
| 235 | + connection: RuntimeConnection.forStdio({ |
| 236 | + path: await getLegacyCliPathForTests(), |
| 237 | + }), |
| 238 | + }); |
| 239 | + try { |
| 240 | + await expectPreToolUseAfterResume(legacyClient); |
| 241 | + } finally { |
| 242 | + await legacyClient.stop(); |
| 243 | + } |
| 244 | + } |
| 245 | + ); |
164 | 246 | }); |
0 commit comments