/*---------------------------------------------------------------------------------------------
* 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.List;
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.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.SessionEndHookInput;
import com.github.copilot.sdk.json.SessionHooks;
import com.github.copilot.sdk.json.SessionStartHookInput;
import com.github.copilot.sdk.json.UserPromptSubmittedHookInput;
/**
* Tests for hooks functionality (pre-tool-use, post-tool-use,
* user-prompt-submitted, session-start, and session-end hooks).
*
*
* These tests use the shared CapiProxy infrastructure for deterministic API
* response replay. Snapshots are stored in test/snapshots/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();
}
}
@Test
void testPreToolUseHookInvokedWhenModelRunsTool() throws Exception {
ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool");
List preToolUseInputs = new ArrayList<>();
final String[] sessionIdHolder = new String[1];
SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> {
preToolUseInputs.add(input);
assertEquals(sessionIdHolder[0], invocation.getSessionId());
return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("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");
}
}
@Test
void testPostToolUseHookInvokedAfterModelRunsTool() throws Exception {
ctx.configureForTest("hooks", "invoke_post_tool_use_hook_after_model_runs_a_tool");
List postToolUseInputs = new ArrayList<>();
final String[] sessionIdHolder = new String[1];
SessionConfig config = new SessionConfig().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");
}
}
@Test
void testBothHooksInvokedForSingleToolCall() throws Exception {
ctx.configureForTest("hooks", "invoke_both_hooks_for_single_tool_call");
List preToolUseInputs = new ArrayList<>();
List postToolUseInputs = new ArrayList<>();
SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> {
preToolUseInputs.add(input);
return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("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");
}
}
@Test
void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception {
ctx.configureForTest("hooks", "deny_tool_execution_when_pre_tool_use_returns_deny");
List preToolUseInputs = new ArrayList<>();
SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> {
preToolUseInputs.add(input);
// Deny all tool calls
return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("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");
}
}
@Test
void testUserPromptSubmittedHookInvokedWhenUserSendsMessage() throws Exception {
ctx.configureForTest("hooks", "invoke_user_prompt_submitted_hook");
List promptInputs = new ArrayList<>();
final String[] sessionIdHolder = new String[1];
SessionConfig config = new SessionConfig()
.setHooks(new SessionHooks().setOnUserPromptSubmitted((input, invocation) -> {
promptInputs.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();
session.sendAndWait(new MessageOptions().setPrompt("Hello, Copilot!")).get(60, TimeUnit.SECONDS);
// Should have received at least one userPromptSubmitted hook call
assertFalse(promptInputs.isEmpty(), "Should have received userPromptSubmitted hook calls");
// Should have received the prompt
assertTrue(promptInputs.stream().anyMatch(i -> i.getPrompt() != null && !i.getPrompt().isEmpty()),
"Should have received prompt in userPromptSubmitted hook");
}
}
@Test
void testSessionStartHookInvokedWhenSessionCreated() throws Exception {
ctx.configureForTest("hooks", "invoke_session_start_hook");
List startInputs = new ArrayList<>();
SessionConfig config = new SessionConfig()
.setHooks(new SessionHooks().setOnSessionStart((input, invocation) -> {
startInputs.add(input);
return CompletableFuture.completedFuture(null);
}));
try (CopilotClient client = ctx.createClient()) {
CopilotSession session = client.createSession(config).get();
// Send a message to trigger the session lifecycle
session.sendAndWait(new MessageOptions().setPrompt("Hello")).get(60, TimeUnit.SECONDS);
// Should have received at least one sessionStart hook call
assertFalse(startInputs.isEmpty(), "Should have received sessionStart hook calls");
// Should have received the source
assertTrue(startInputs.stream().anyMatch(i -> i.getSource() != null),
"Should have received source in sessionStart hook");
}
}
@Test
void testSessionEndHookInvokedWhenSessionEnds() throws Exception {
ctx.configureForTest("hooks", "invoke_session_end_hook");
List endInputs = new ArrayList<>();
SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnSessionEnd((input, invocation) -> {
endInputs.add(input);
return CompletableFuture.completedFuture(null);
}));
try (CopilotClient client = ctx.createClient()) {
CopilotSession session = client.createSession(config).get();
// Send a message and wait for completion
session.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS);
// Should have received at least one sessionEnd hook call
assertFalse(endInputs.isEmpty(), "Should have received sessionEnd hook calls");
// Should have received the reason
assertTrue(endInputs.stream().anyMatch(i -> i.getReason() != null),
"Should have received reason in sessionEnd hook");
}
}
}