diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e8245f97d..c3597fbdf 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1100,6 +1100,12 @@ public class HookInvocation /// public class PreToolUseHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the tool use was initiated. /// @@ -1176,6 +1182,12 @@ public class PreToolUseHookOutput /// public class PostToolUseHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the tool execution completed. /// @@ -1241,6 +1253,12 @@ public class PostToolUseHookOutput /// public class UserPromptSubmittedHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the prompt was submitted. /// @@ -1294,6 +1312,12 @@ public class UserPromptSubmittedHookOutput /// public class SessionStartHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the session started. /// @@ -1352,6 +1376,12 @@ public class SessionStartHookOutput /// public class SessionEndHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the session ended. /// @@ -1424,6 +1454,12 @@ public class SessionEndHookOutput /// public class ErrorOccurredHookInput { + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + /// /// Unix timestamp in milliseconds when the error occurred. /// diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs new file mode 100644 index 000000000..1a9c8ffa1 --- /dev/null +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Concurrent; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test.E2E; + +public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "subagent_hooks", output) +{ + [Fact] + public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls() + { + var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>(); + + // Create a client with the session-based subagents feature flag + var env = new Dictionary(Ctx.GetEnvironment()); + env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; + var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + + var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + hookLog.Add(("pre", input.ToolName, input.SessionId)); + return Task.FromResult(new PreToolUseHookOutput + { + PermissionDecision = "allow" + }); + }, + OnPostToolUse = (input, invocation) => + { + hookLog.Add(("post", input.ToolName, input.SessionId)); + return Task.FromResult(null); + }, + }, + }); + + // Create a file for the sub-agent to read + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "subagent-test.txt"), "Hello from subagent test!"); + + await session.SendAndWaitAsync( + new MessageOptions + { + Prompt = "Use the task tool to spawn an explore agent that reads the file " + + "subagent-test.txt in the current directory and reports its contents. " + + "You must use the task tool." + }, + timeout: TimeSpan.FromSeconds(120)); + + var log = hookLog.ToArray(); + + // Parent tool hooks fire for "task" + var taskPre = log.Where(h => h.Kind == "pre" && h.ToolName == "task").ToArray(); + Assert.True(taskPre.Length >= 1, "preToolUse should fire for the parent's 'task' tool call"); + + // Sub-agent tool hooks fire for "view" + var viewPre = log.Where(h => h.Kind == "pre" && h.ToolName == "view").ToArray(); + var viewPost = log.Where(h => h.Kind == "post" && h.ToolName == "view").ToArray(); + Assert.True(viewPre.Length > 0, "preToolUse should fire for the sub-agent's 'view' tool call"); + Assert.True(viewPost.Length > 0, "postToolUse should fire for the sub-agent's 'view' tool call"); + + // input.SessionId distinguishes parent from sub-agent + Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId); + } +} diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go new file mode 100644 index 000000000..6058cb8d7 --- /dev/null +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -0,0 +1,103 @@ +package e2e + +import ( + "os" + "path/filepath" + "sync" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestSubagentHooksE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + }) + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type hookEntry struct { + kind string + toolName string + sessionID string + } + var hookLog []hookEntry + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + hookLog = append(hookLog, hookEntry{kind: "pre", toolName: input.ToolName, sessionID: input.SessionID}) + mu.Unlock() + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + hookLog = append(hookLog, hookEntry{kind: "post", toolName: input.ToolName, sessionID: input.SessionID}) + mu.Unlock() + return nil, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Create a file for the sub-agent to read + testFile := filepath.Join(ctx.WorkDir, "subagent-test.txt") + if err := os.WriteFile(testFile, []byte("Hello from subagent test!"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + // Parent tool hooks fire for "task" + var taskPre *hookEntry + for i := range hookLog { + if hookLog[i].kind == "pre" && hookLog[i].toolName == "task" { + taskPre = &hookLog[i] + break + } + } + if taskPre == nil { + t.Fatal("preToolUse should fire for the parent's 'task' tool call") + } + + // Sub-agent tool hooks fire for "view" + var viewPre, viewPost []hookEntry + for _, h := range hookLog { + if h.toolName == "view" { + if h.kind == "pre" { + viewPre = append(viewPre, h) + } else { + viewPost = append(viewPost, h) + } + } + } + if len(viewPre) == 0 { + t.Fatal("preToolUse should fire for the sub-agent's 'view' tool call") + } + if len(viewPost) == 0 { + t.Fatal("postToolUse should fire for the sub-agent's 'view' tool call") + } + + // input.SessionID distinguishes parent from sub-agent + if viewPre[0].sessionID == taskPre.sessionID { + t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks") + } + }) +} diff --git a/go/types.go b/go/types.go index 562019e59..264e49664 100644 --- a/go/types.go +++ b/go/types.go @@ -337,6 +337,7 @@ type AutoModeSwitchHandler func(request AutoModeSwitchRequest, invocation AutoMo // PreToolUseHookInput is the input for a pre-tool-use hook type PreToolUseHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` ToolName string `json:"toolName"` @@ -357,6 +358,7 @@ type PreToolUseHandler func(input PreToolUseHookInput, invocation HookInvocation // PostToolUseHookInput is the input for a post-tool-use hook type PostToolUseHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` ToolName string `json:"toolName"` @@ -376,6 +378,7 @@ type PostToolUseHandler func(input PostToolUseHookInput, invocation HookInvocati // UserPromptSubmittedHookInput is the input for a user-prompt-submitted hook type UserPromptSubmittedHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` Prompt string `json:"prompt"` @@ -393,6 +396,7 @@ type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocat // SessionStartHookInput is the input for a session-start hook type SessionStartHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` Source string `json:"source"` // "startup", "resume", "new" @@ -410,6 +414,7 @@ type SessionStartHandler func(input SessionStartHookInput, invocation HookInvoca // SessionEndHookInput is the input for a session-end hook type SessionEndHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" @@ -429,6 +434,7 @@ type SessionEndHandler func(input SessionEndHookInput, invocation HookInvocation // ErrorOccurredHookInput is the input for an error-occurred hook type ErrorOccurredHookInput struct { + SessionID string `json:"sessionId"` Timestamp int64 `json:"timestamp"` Cwd string `json:"cwd"` Error string `json:"error"` diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 200363fdc..538c9dba0 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -917,6 +917,9 @@ export type AutoModeSwitchHandler = ( * Base interface for all hook inputs */ export interface BaseHookInput { + /** The runtime session ID of the session that triggered the hook. + * For sub-agent hooks this differs from `invocation.sessionId`. */ + sessionId: string; timestamp: number; cwd: string; } diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts new file mode 100644 index 000000000..0e6c2e95e --- /dev/null +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { 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, isCI } from "./harness/sdkTestContext.js"; + +describe("Subagent hooks", async () => { + // For snapshot recording (non-CI), use RECORD_GH_TOKEN if available + const recordToken = !isCI ? process.env.RECORD_GH_TOKEN : undefined; + const { + copilotClient: client, + workDir, + env, + } = await createSdkTestContext({ + ...(recordToken ? { copilotClientOptions: { gitHubToken: recordToken } } : {}), + }); + // Sub-agent hook propagation requires the session-based subagents feature flag. + // Without this flag, the legacy callback-bridge path is used, which does not + // support SDK preToolUse/postToolUse hooks for sub-agent tool calls. + env.COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS = "true"; + + it("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", async () => { + const hookLog: { kind: "pre" | "post"; toolName: string; sessionId: string }[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPreToolUse: async (input: PreToolUseHookInput) => { + hookLog.push({ + kind: "pre", + toolName: input.toolName, + sessionId: input.sessionId, + }); + return { permissionDecision: "allow" } as PreToolUseHookOutput; + }, + onPostToolUse: async (input: PostToolUseHookInput) => { + hookLog.push({ + kind: "post", + toolName: input.toolName, + sessionId: input.sessionId, + }); + return null as PostToolUseHookOutput; + }, + }, + }); + + // Create a file for the sub-agent to read + await writeFile(join(workDir, "subagent-test.txt"), "Hello from subagent test!"); + + await session.sendAndWait({ + prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + }); + + // Parent tool hooks fire for "task" + const taskPre = hookLog.find((h) => h.kind === "pre" && h.toolName === "task"); + expect(taskPre, "preToolUse should fire for the parent's 'task' tool call").toBeDefined(); + + // Sub-agent tool hooks fire for "view" + const viewPre = hookLog.filter((h) => h.kind === "pre" && h.toolName === "view"); + const viewPost = hookLog.filter((h) => h.kind === "post" && h.toolName === "view"); + expect( + viewPre.length, + "preToolUse should fire for the sub-agent's 'view' tool call" + ).toBeGreaterThan(0); + expect( + viewPost.length, + "postToolUse should fire for the sub-agent's 'view' tool call" + ).toBeGreaterThan(0); + + // input.sessionId distinguishes parent from sub-agent: parent tools and + // sub-agent tools carry different sessionIds + expect(viewPre[0].sessionId).not.toBe(taskPre!.sessionId); + + await session.disconnect(); + }, 120_000); +}); diff --git a/python/copilot/session.py b/python/copilot/session.py index efe89ce48..f243d86e1 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -610,16 +610,10 @@ async def input(self, message: str, options: InputOptions | None = None) -> str # ============================================================================ -class BaseHookInput(TypedDict): - """Base interface for all hook inputs""" - - timestamp: int - cwd: str - - class PreToolUseHookInput(TypedDict): """Input for pre-tool-use hook""" + sessionId: str timestamp: int cwd: str toolName: str @@ -645,6 +639,7 @@ class PreToolUseHookOutput(TypedDict, total=False): class PostToolUseHookInput(TypedDict): """Input for post-tool-use hook""" + sessionId: str timestamp: int cwd: str toolName: str @@ -669,6 +664,7 @@ class PostToolUseHookOutput(TypedDict, total=False): class UserPromptSubmittedHookInput(TypedDict): """Input for user-prompt-submitted hook""" + sessionId: str timestamp: int cwd: str prompt: str @@ -691,6 +687,7 @@ class UserPromptSubmittedHookOutput(TypedDict, total=False): class SessionStartHookInput(TypedDict): """Input for session-start hook""" + sessionId: str timestamp: int cwd: str source: Literal["startup", "resume", "new"] @@ -713,6 +710,7 @@ class SessionStartHookOutput(TypedDict, total=False): class SessionEndHookInput(TypedDict): """Input for session-end hook""" + sessionId: str timestamp: int cwd: str reason: Literal["complete", "error", "abort", "timeout", "user_exit"] @@ -737,6 +735,7 @@ class SessionEndHookOutput(TypedDict, total=False): class ErrorOccurredHookInput(TypedDict): """Input for error-occurred hook""" + sessionId: str timestamp: int cwd: str error: str diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py new file mode 100644 index 000000000..57e19d5e5 --- /dev/null +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -0,0 +1,94 @@ +""" +Tests for sub-agent hooks functionality — verifies preToolUse/postToolUse hooks +fire for tool calls made by sub-agents spawned via the task tool. +""" + +import os + +import pytest + +from copilot.client import CopilotClient, SubprocessConfig +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext +from .testharness.helper import write_file + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestSubagentHooks: + async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls( + self, ctx: E2ETestContext + ): + """Test that preToolUse/postToolUse hooks fire for sub-agent tool calls""" + hook_log = [] + + async def on_pre_tool_use(input_data, invocation): + hook_log.append( + { + "kind": "pre", + "toolName": input_data.get("toolName"), + "sessionId": input_data.get("sessionId"), + } + ) + return {"permissionDecision": "allow"} + + async def on_post_tool_use(input_data, invocation): + hook_log.append( + { + "kind": "post", + "toolName": input_data.get("toolName"), + "sessionId": input_data.get("sessionId"), + } + ) + return None + + # Create a client with the session-based subagents feature flag + env = ctx.get_env() + env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true" + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) + client = CopilotClient( + SubprocessConfig( + cli_path=ctx.cli_path, + cwd=ctx.work_dir, + env=env, + github_token=github_token, + ) + ) + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={ + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + }, + ) + + # Create a file for the sub-agent to read + write_file(ctx.work_dir, "subagent-test.txt", "Hello from subagent test!") + + await session.send_and_wait( + "Use the task tool to spawn an explore agent that reads the file " + "subagent-test.txt in the current directory and reports its contents. " + "You must use the task tool." + ) + + # Parent tool hooks fire for "task" + task_pre = [h for h in hook_log if h["kind"] == "pre" and h["toolName"] == "task"] + assert len(task_pre) >= 1, "preToolUse should fire for the parent's 'task' tool call" + + # Sub-agent tool hooks fire for "view" + view_pre = [h for h in hook_log if h["kind"] == "pre" and h["toolName"] == "view"] + view_post = [h for h in hook_log if h["kind"] == "post" and h["toolName"] == "view"] + assert len(view_pre) > 0, "preToolUse should fire for the sub-agent's 'view' tool call" + assert len(view_post) > 0, "postToolUse should fire for the sub-agent's 'view' tool call" + + # input.sessionId distinguishes parent from sub-agent + assert view_pre[0]["sessionId"] != task_pre[0]["sessionId"], ( + "Sub-agent tool hooks should have a different sessionId than parent tool hooks" + ) + + await session.disconnect() + await client.stop() diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index f8a92ebc1..e224bab91 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -25,6 +25,8 @@ pub struct HookContext { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreToolUseInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -60,6 +62,8 @@ pub struct PreToolUseOutput { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PostToolUseInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -91,6 +95,8 @@ pub struct PostToolUseOutput { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserPromptSubmittedInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -118,6 +124,8 @@ pub struct UserPromptSubmittedOutput { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStartInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -145,6 +153,8 @@ pub struct SessionStartOutput { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionEndInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -178,6 +188,8 @@ pub struct SessionEndOutput { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ErrorOccurredInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. @@ -540,6 +552,7 @@ mod tests { async fn dispatch_pre_tool_use_deny() { let hooks = TestHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "dangerous_tool", @@ -557,6 +570,7 @@ mod tests { async fn dispatch_pre_tool_use_passthrough() { let hooks = TestHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "safe_tool", @@ -573,6 +587,7 @@ mod tests { async fn dispatch_user_prompt_submitted() { let hooks = TestHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "prompt": "hello world" @@ -592,6 +607,7 @@ mod tests { async fn dispatch_unregistered_hook_returns_empty() { let hooks = TestHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "reason": "complete" @@ -629,6 +645,7 @@ mod tests { let hooks = MismatchHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", @@ -645,6 +662,7 @@ mod tests { async fn dispatch_post_tool_use_default() { let hooks = TestHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "some_tool", @@ -677,6 +695,7 @@ mod tests { let hooks = StartHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "source": "new" @@ -708,6 +727,7 @@ mod tests { let hooks = ErrorHooks; let input = serde_json::json!({ + "sessionId": "sess-1", "timestamp": 1234567890, "cwd": "/tmp", "error": "model timeout", diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 8fefdf23a..cb75dfec5 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -77,6 +77,8 @@ mod session_lifecycle; mod skills; #[path = "e2e/streaming_fidelity.rs"] mod streaming_fidelity; +#[path = "e2e/subagent_hooks.rs"] +mod subagent_hooks; #[path = "e2e/support.rs"] mod support; #[path = "e2e/suspend.rs"] diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs new file mode 100644 index 000000000..99529c433 --- /dev/null +++ b/rust/tests/e2e/subagent_hooks.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::hooks::{ + HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, + SessionHooks, +}; +use parking_lot::Mutex; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls() { + with_e2e_context( + "subagent_hooks", + "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write( + ctx.work_dir().join("subagent-test.txt"), + "Hello from subagent test!", + ) + .expect("write test file"); + + let hook_log = Arc::new(Mutex::new(Vec::::new())); + + let mut opts = ctx.client_options(); + opts.env.push(( + "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(), + "true".into(), + )); + + let client = github_copilot_sdk::Client::start(opts) + .await + .expect("start client"); + + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + log: Arc::clone(&hook_log), + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait( + "Use the task tool to spawn an explore agent that reads the file \ + subagent-test.txt in the current directory and reports its contents. \ + You must use the task tool.", + ) + .await + .expect("send"); + + let log = hook_log.lock().clone(); + + // Parent tool hooks fire for "task" + let task_pre = log + .iter() + .find(|h| h.kind == "pre" && h.tool_name == "task"); + assert!( + task_pre.is_some(), + "preToolUse should fire for the parent's 'task' tool call" + ); + + // Sub-agent tool hooks fire for "view" + let view_pre: Vec<_> = log + .iter() + .filter(|h| h.kind == "pre" && h.tool_name == "view") + .collect(); + let view_post: Vec<_> = log + .iter() + .filter(|h| h.kind == "post" && h.tool_name == "view") + .collect(); + assert!( + !view_pre.is_empty(), + "preToolUse should fire for the sub-agent's 'view' tool call" + ); + assert!( + !view_post.is_empty(), + "postToolUse should fire for the sub-agent's 'view' tool call" + ); + + // input.session_id distinguishes parent from sub-agent + assert_ne!( + view_pre[0].session_id, + task_pre.unwrap().session_id, + "Sub-agent tool hooks should have a different sessionId than parent tool hooks" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[derive(Clone, Debug)] +struct HookEntry { + kind: String, + tool_name: String, + session_id: String, +} + +struct RecordingHooks { + log: Arc>>, +} + +#[async_trait] +impl SessionHooks for RecordingHooks { + async fn on_pre_tool_use( + &self, + input: PreToolUseInput, + _ctx: HookContext, + ) -> Option { + self.log.lock().push(HookEntry { + kind: "pre".to_string(), + tool_name: input.tool_name, + session_id: input.session_id, + }); + Some(PreToolUseOutput { + permission_decision: Some("allow".to_string()), + ..PreToolUseOutput::default() + }) + } + + async fn on_post_tool_use( + &self, + input: PostToolUseInput, + _ctx: HookContext, + ) -> Option { + self.log.lock().push(HookEntry { + kind: "post".to_string(), + tool_name: input.tool_name, + session_id: input.session_id, + }); + None + } +} diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 32196fdda..e83d3d01c 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -2245,6 +2245,7 @@ async fn hooks_invoke_dispatches_to_session_hooks() { "sessionId": server.session_id, "hookType": "preToolUse", "input": { + "sessionId": "test-session", "timestamp": 1234567890, "cwd": "/tmp", "toolName": "rm", @@ -2282,6 +2283,7 @@ async fn hooks_invoke_returns_empty_for_unregistered_hook() { "sessionId": server.session_id, "hookType": "sessionEnd", "input": { + "sessionId": "test-session", "timestamp": 1234567890, "cwd": "/tmp", "reason": "complete" diff --git a/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml b/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml new file mode 100644 index 000000000..ea2aa5093 --- /dev/null +++ b/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml @@ -0,0 +1,138 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and + reports its contents. You must use the task tool. + - role: assistant + content: I'll spawn an explore agent to read the file and report its contents. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Spawning explore agent"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: task + arguments: '{"agent_type":"explore","name":"read-file","description":"Reading subagent-test.txt","prompt":"Read the file + \"subagent-test.txt\" in the current directory (${workdir}) and report its complete contents. Use the + view tool to read the file and provide the full content in your response.","mode":"background"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file "subagent-test.txt" in the current directory (${workdir}) and report its complete contents. Use + the view tool to read the file and provide the full content in your response. + - role: assistant + content: I'll read the file "subagent-test.txt" from the current directory. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: view + arguments: '{"path":"${workdir}/subagent-test.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file "subagent-test.txt" in the current directory (${workdir}) and report its complete contents. Use + the view tool to read the file and provide the full content in your response. + - role: assistant + content: I'll read the file "subagent-test.txt" from the current directory. + tool_calls: + - id: toolcall_0 + type: function + function: + name: view + arguments: '{"path":"${workdir}/subagent-test.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: 1. Hello from subagent test! + - role: assistant + content: |- + The complete contents of the file "subagent-test.txt" are: + + ``` + Hello from subagent test! + ``` + + The file contains a single line with the text "Hello from subagent test!". + - messages: + - role: system + content: ${system} + - role: user + content: Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and + reports its contents. You must use the task tool. + - role: assistant + content: I'll spawn an explore agent to read the file and report its contents. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Spawning explore agent"}' + - id: toolcall_1 + type: function + function: + name: task + arguments: '{"agent_type":"explore","name":"read-file","description":"Reading subagent-test.txt","prompt":"Read the file + \"subagent-test.txt\" in the current directory (${workdir}) and report its complete contents. Use the + view tool to read the file and provide the full content in your response.","mode":"background"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: "Agent started in background with agent_id: read-file. You'll be notified when it completes. Tell the user + you're waiting and end your response, or continue unrelated work until notified." + - role: assistant + content: I've spawned an explore agent to read the file. Waiting for it to complete... + - role: user + content: >- + + + Agent "read-file" (explore) has completed successfully. Use read_agent with agent_id "read-file" to retrieve + unread results. + + + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: read_agent + arguments: '{"agent_id":"read-file","wait":true}' + - role: tool + tool_call_id: toolcall_2 + content: >- + Agent completed. agent_id: read-file, agent_type: explore, status: completed, description: Reading + subagent-test.txt, elapsed: 0s, total_turns: 0, duration: 0s + + + The complete contents of the file "subagent-test.txt" are: + + + ``` + + Hello from subagent test! + + ``` + + + The file contains a single line with the text "Hello from subagent test!". + - role: assistant + content: |- + The explore agent successfully read the file. The contents of `subagent-test.txt` are: + + ``` + Hello from subagent test! + ```