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!
+ ```