/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import static org.junit.jupiter.api.Assertions.*; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PostToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookOutput; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.SessionHooks; /** * Tests for hooks functionality (pre-tool-use and post-tool-use hooks). * *

* These tests use the shared CapiProxy infrastructure for deterministic API * response replay. Snapshots are stored in test/snapshots/hooks/. *

* *

* Note: Tests for userPromptSubmitted, sessionStart, and sessionEnd hooks are * not included as they are not tested in the upstream .NET or Node.js SDKs and * require test harness updates to properly invoke these hooks. *

*/ public class HooksTest { private static E2ETestContext ctx; @BeforeAll static void setup() throws Exception { ctx = E2ETestContext.create(); } @AfterAll static void teardown() throws Exception { if (ctx != null) { ctx.close(); } } /** * Verifies that pre-tool-use hook is invoked when model runs a tool. * * @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool */ @Test void testInvokePreToolUseHookWhenModelRunsATool() throws Exception { ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool"); var preToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); assertEquals(sessionIdHolder[0], invocation.getSessionId()); return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); sessionIdHolder[0] = session.getSessionId(); // Create a file for the model to read Path testFile = ctx.getWorkDir().resolve("hello.txt"); Files.writeString(testFile, "Hello from the test!"); session.sendAndWait( new MessageOptions().setPrompt("Read the contents of hello.txt and tell me what it says")) .get(60, TimeUnit.SECONDS); // Should have received at least one preToolUse hook call assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); // Should have received the tool name assertTrue(preToolUseInputs.stream().anyMatch(i -> i.getToolName() != null && !i.getToolName().isEmpty()), "Should have received tool name in preToolUse hook"); } } /** * Verifies that post-tool-use hook is invoked after model runs a tool. * * @see Snapshot: hooks/invoke_post_tool_use_hook_after_model_runs_a_tool */ @Test void testInvokePostToolUseHookAfterModelRunsATool() throws Exception { ctx.configureForTest("hooks", "invoke_post_tool_use_hook_after_model_runs_a_tool"); var postToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { postToolUseInputs.add(input); assertEquals(sessionIdHolder[0], invocation.getSessionId()); return CompletableFuture.completedFuture(null); })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); sessionIdHolder[0] = session.getSessionId(); // Create a file for the model to read Path testFile = ctx.getWorkDir().resolve("world.txt"); Files.writeString(testFile, "World from the test!"); session.sendAndWait( new MessageOptions().setPrompt("Read the contents of world.txt and tell me what it says")) .get(60, TimeUnit.SECONDS); // Should have received at least one postToolUse hook call assertFalse(postToolUseInputs.isEmpty(), "Should have received postToolUse hook calls"); // Should have received the tool name and result assertTrue(postToolUseInputs.stream().anyMatch(i -> i.getToolName() != null && !i.getToolName().isEmpty()), "Should have received tool name in postToolUse hook"); assertTrue(postToolUseInputs.stream().anyMatch(i -> i.getToolResult() != null), "Should have received tool result in postToolUse hook"); } } /** * Verifies that both hooks are invoked for a single tool call. * * @see Snapshot: hooks/invoke_both_hooks_for_single_tool_call */ @Test void testInvokeBothHooksForSingleToolCall() throws Exception { ctx.configureForTest("hooks", "invoke_both_hooks_for_single_tool_call"); var preToolUseInputs = new ArrayList(); var postToolUseInputs = new ArrayList(); var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); }).setOnPostToolUse((input, invocation) -> { postToolUseInputs.add(input); return CompletableFuture.completedFuture(null); })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); // Create a file for the model to read Path testFile = ctx.getWorkDir().resolve("both.txt"); Files.writeString(testFile, "Testing both hooks!"); session.sendAndWait(new MessageOptions().setPrompt("Read the contents of both.txt")).get(60, TimeUnit.SECONDS); // Both hooks should have been called assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); assertFalse(postToolUseInputs.isEmpty(), "Should have received postToolUse hook calls"); // The same tool should appear in both Set preToolNames = preToolUseInputs.stream().map(PreToolUseHookInput::getToolName) .filter(n -> n != null && !n.isEmpty()).collect(Collectors.toSet()); Set postToolNames = postToolUseInputs.stream().map(PostToolUseHookInput::getToolName) .filter(n -> n != null && !n.isEmpty()).collect(Collectors.toSet()); // Check if there's any overlap boolean hasOverlap = preToolNames.stream().anyMatch(postToolNames::contains); assertTrue(hasOverlap, "Expected the same tool to appear in both pre and post hooks"); } } /** * Verifies that tool execution is denied when pre-tool-use returns deny. * * @see Snapshot: hooks/deny_tool_execution_when_pre_tool_use_returns_deny */ @Test void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { ctx.configureForTest("hooks", "deny_tool_execution_when_pre_tool_use_returns_deny"); var preToolUseInputs = new ArrayList(); var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); // Deny all tool calls return CompletableFuture.completedFuture(PreToolUseHookOutput.deny()); })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); // Create a file Path testFile = ctx.getWorkDir().resolve("protected.txt"); String originalContent = "Original content that should not be modified"; Files.writeString(testFile, originalContent); var response = session .sendAndWait( new MessageOptions().setPrompt("Edit protected.txt and replace 'Original' with 'Modified'")) .get(60, TimeUnit.SECONDS); // The hook should have been called assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); // The response should be defined assertNotNull(response, "Response should not be null"); } } }