/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ import { readFile, writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { PreToolUseHookInput, PreToolUseHookOutput, PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Session hooks", async () => { const { copilotClient: client, workDir } = await createSdkTestContext(); it("should invoke preToolUse hook when model runs a tool", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const invocationSessionIds: string[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); invocationSessionIds.push(invocation.sessionId); // Allow the tool to run return { permissionDecision: "allow" } as PreToolUseHookOutput; }, }, }); // Create a file for the model to read await writeFile(join(workDir, "hello.txt"), "Hello from the test!"); await session.sendAndWait({ prompt: "Read the contents of hello.txt and tell me what it says", }); // Should have received at least one preToolUse hook call expect(preToolUseInputs.length).toBeGreaterThan(0); expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( true ); // Should have received the tool name expect(preToolUseInputs.some((input) => input.toolName)).toBe(true); await session.disconnect(); }); it("should invoke postToolUse hook after model runs a tool", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const invocationSessionIds: string[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); invocationSessionIds.push(invocation.sessionId); return null as PostToolUseHookOutput; }, }, }); // Create a file for the model to read await writeFile(join(workDir, "world.txt"), "World from the test!"); await session.sendAndWait({ prompt: "Read the contents of world.txt and tell me what it says", }); // Should have received at least one postToolUse hook call expect(postToolUseInputs.length).toBeGreaterThan(0); expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( true ); // Should have received the tool name and result expect(postToolUseInputs.some((input) => input.toolName)).toBe(true); expect(postToolUseInputs.some((input) => input.toolResult !== undefined)).toBe(true); await session.disconnect(); }); it("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); return { permissionDecision: "allow" } as PreToolUseHookOutput; }, onPostToolUse: async (input) => { postToolUseInputs.push(input); return null as PostToolUseHookOutput; }, }, }); await writeFile(join(workDir, "both.txt"), "Testing both hooks!"); await session.sendAndWait({ prompt: "Read the contents of both.txt", }); // Both hooks should have been called expect(preToolUseInputs.length).toBeGreaterThan(0); expect(postToolUseInputs.length).toBeGreaterThan(0); // The same tool should appear in both const preToolNames = preToolUseInputs.map((i) => i.toolName); const postToolNames = postToolUseInputs.map((i) => i.toolName); const commonTool = preToolNames.find((name) => postToolNames.includes(name)); expect(commonTool).toBeDefined(); await session.disconnect(); }); it("should deny tool execution when preToolUse returns deny", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); // Deny all tool calls return { permissionDecision: "deny" } as PreToolUseHookOutput; }, }, }); // Create a file const originalContent = "Original content that should not be modified"; await writeFile(join(workDir, "protected.txt"), originalContent); const response = await session.sendAndWait({ prompt: "Edit protected.txt and replace 'Original' with 'Modified'", }); // The hook should have been called expect(preToolUseInputs.length).toBeGreaterThan(0); // The response should indicate the tool was denied (behavior may vary) // At minimum, we verify the hook was invoked expect(response).toBeDefined(); // Strengthen: verify the actual deny behavior — the protected file was NOT // modified by the runtime even though the LLM tried to edit it. The // pre-tool-use hook denial blocks tool execution before it can mutate state. const actualContent = await readFile(join(workDir, "protected.txt"), "utf-8"); expect(actualContent).toBe(originalContent); await session.disconnect(); }); });