Skip to content

Commit f6c1adf

Browse files
Fix sub-agent hook propagation: expose sessionId on hook inputs (#1290)
1 parent 8410ed2 commit f6c1adf

13 files changed

Lines changed: 710 additions & 7 deletions

File tree

dotnet/src/Types.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,12 @@ public class HookInvocation
11001100
/// </summary>
11011101
public class PreToolUseHookInput
11021102
{
1103+
/// <summary>
1104+
/// The runtime session ID of the session that triggered the hook.
1105+
/// </summary>
1106+
[JsonPropertyName("sessionId")]
1107+
public string SessionId { get; set; } = string.Empty;
1108+
11031109
/// <summary>
11041110
/// Unix timestamp in milliseconds when the tool use was initiated.
11051111
/// </summary>
@@ -1176,6 +1182,12 @@ public class PreToolUseHookOutput
11761182
/// </summary>
11771183
public class PostToolUseHookInput
11781184
{
1185+
/// <summary>
1186+
/// The runtime session ID of the session that triggered the hook.
1187+
/// </summary>
1188+
[JsonPropertyName("sessionId")]
1189+
public string SessionId { get; set; } = string.Empty;
1190+
11791191
/// <summary>
11801192
/// Unix timestamp in milliseconds when the tool execution completed.
11811193
/// </summary>
@@ -1241,6 +1253,12 @@ public class PostToolUseHookOutput
12411253
/// </summary>
12421254
public class UserPromptSubmittedHookInput
12431255
{
1256+
/// <summary>
1257+
/// The runtime session ID of the session that triggered the hook.
1258+
/// </summary>
1259+
[JsonPropertyName("sessionId")]
1260+
public string SessionId { get; set; } = string.Empty;
1261+
12441262
/// <summary>
12451263
/// Unix timestamp in milliseconds when the prompt was submitted.
12461264
/// </summary>
@@ -1294,6 +1312,12 @@ public class UserPromptSubmittedHookOutput
12941312
/// </summary>
12951313
public class SessionStartHookInput
12961314
{
1315+
/// <summary>
1316+
/// The runtime session ID of the session that triggered the hook.
1317+
/// </summary>
1318+
[JsonPropertyName("sessionId")]
1319+
public string SessionId { get; set; } = string.Empty;
1320+
12971321
/// <summary>
12981322
/// Unix timestamp in milliseconds when the session started.
12991323
/// </summary>
@@ -1352,6 +1376,12 @@ public class SessionStartHookOutput
13521376
/// </summary>
13531377
public class SessionEndHookInput
13541378
{
1379+
/// <summary>
1380+
/// The runtime session ID of the session that triggered the hook.
1381+
/// </summary>
1382+
[JsonPropertyName("sessionId")]
1383+
public string SessionId { get; set; } = string.Empty;
1384+
13551385
/// <summary>
13561386
/// Unix timestamp in milliseconds when the session ended.
13571387
/// </summary>
@@ -1424,6 +1454,12 @@ public class SessionEndHookOutput
14241454
/// </summary>
14251455
public class ErrorOccurredHookInput
14261456
{
1457+
/// <summary>
1458+
/// The runtime session ID of the session that triggered the hook.
1459+
/// </summary>
1460+
[JsonPropertyName("sessionId")]
1461+
public string SessionId { get; set; } = string.Empty;
1462+
14271463
/// <summary>
14281464
/// Unix timestamp in milliseconds when the error occurred.
14291465
/// </summary>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using System.Collections.Concurrent;
6+
using GitHub.Copilot.SDK.Test.Harness;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
10+
namespace GitHub.Copilot.SDK.Test.E2E;
11+
12+
public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
13+
: E2ETestBase(fixture, "subagent_hooks", output)
14+
{
15+
[Fact]
16+
public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls()
17+
{
18+
var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>();
19+
20+
// Create a client with the session-based subagents feature flag
21+
var env = new Dictionary<string, string>(Ctx.GetEnvironment());
22+
env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true";
23+
var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env });
24+
25+
var session = await client.CreateSessionAsync(new SessionConfig
26+
{
27+
OnPermissionRequest = PermissionHandler.ApproveAll,
28+
Hooks = new SessionHooks
29+
{
30+
OnPreToolUse = (input, invocation) =>
31+
{
32+
hookLog.Add(("pre", input.ToolName, input.SessionId));
33+
return Task.FromResult<PreToolUseHookOutput?>(new PreToolUseHookOutput
34+
{
35+
PermissionDecision = "allow"
36+
});
37+
},
38+
OnPostToolUse = (input, invocation) =>
39+
{
40+
hookLog.Add(("post", input.ToolName, input.SessionId));
41+
return Task.FromResult<PostToolUseHookOutput?>(null);
42+
},
43+
},
44+
});
45+
46+
// Create a file for the sub-agent to read
47+
await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "subagent-test.txt"), "Hello from subagent test!");
48+
49+
await session.SendAndWaitAsync(
50+
new MessageOptions
51+
{
52+
Prompt = "Use the task tool to spawn an explore agent that reads the file "
53+
+ "subagent-test.txt in the current directory and reports its contents. "
54+
+ "You must use the task tool."
55+
},
56+
timeout: TimeSpan.FromSeconds(120));
57+
58+
var log = hookLog.ToArray();
59+
60+
// Parent tool hooks fire for "task"
61+
var taskPre = log.Where(h => h.Kind == "pre" && h.ToolName == "task").ToArray();
62+
Assert.True(taskPre.Length >= 1, "preToolUse should fire for the parent's 'task' tool call");
63+
64+
// Sub-agent tool hooks fire for "view"
65+
var viewPre = log.Where(h => h.Kind == "pre" && h.ToolName == "view").ToArray();
66+
var viewPost = log.Where(h => h.Kind == "post" && h.ToolName == "view").ToArray();
67+
Assert.True(viewPre.Length > 0, "preToolUse should fire for the sub-agent's 'view' tool call");
68+
Assert.True(viewPost.Length > 0, "postToolUse should fire for the sub-agent's 'view' tool call");
69+
70+
// input.SessionId distinguishes parent from sub-agent
71+
Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId);
72+
}
73+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package e2e
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sync"
7+
"testing"
8+
9+
copilot "github.com/github/copilot-sdk/go"
10+
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
11+
)
12+
13+
func TestSubagentHooksE2E(t *testing.T) {
14+
ctx := testharness.NewTestContext(t)
15+
client := ctx.NewClient(func(o *copilot.ClientOptions) {
16+
o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true")
17+
})
18+
t.Cleanup(func() { client.ForceStop() })
19+
20+
t.Run("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", func(t *testing.T) {
21+
ctx.ConfigureForTest(t)
22+
23+
type hookEntry struct {
24+
kind string
25+
toolName string
26+
sessionID string
27+
}
28+
var hookLog []hookEntry
29+
var mu sync.Mutex
30+
31+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
32+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
33+
Hooks: &copilot.SessionHooks{
34+
OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) {
35+
mu.Lock()
36+
hookLog = append(hookLog, hookEntry{kind: "pre", toolName: input.ToolName, sessionID: input.SessionID})
37+
mu.Unlock()
38+
return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil
39+
},
40+
OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) {
41+
mu.Lock()
42+
hookLog = append(hookLog, hookEntry{kind: "post", toolName: input.ToolName, sessionID: input.SessionID})
43+
mu.Unlock()
44+
return nil, nil
45+
},
46+
},
47+
})
48+
if err != nil {
49+
t.Fatalf("Failed to create session: %v", err)
50+
}
51+
52+
// Create a file for the sub-agent to read
53+
testFile := filepath.Join(ctx.WorkDir, "subagent-test.txt")
54+
if err := os.WriteFile(testFile, []byte("Hello from subagent test!"), 0644); err != nil {
55+
t.Fatalf("Failed to write test file: %v", err)
56+
}
57+
58+
_, err = session.SendAndWait(t.Context(), copilot.MessageOptions{
59+
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.",
60+
})
61+
if err != nil {
62+
t.Fatalf("Failed to send message: %v", err)
63+
}
64+
65+
mu.Lock()
66+
defer mu.Unlock()
67+
68+
// Parent tool hooks fire for "task"
69+
var taskPre *hookEntry
70+
for i := range hookLog {
71+
if hookLog[i].kind == "pre" && hookLog[i].toolName == "task" {
72+
taskPre = &hookLog[i]
73+
break
74+
}
75+
}
76+
if taskPre == nil {
77+
t.Fatal("preToolUse should fire for the parent's 'task' tool call")
78+
}
79+
80+
// Sub-agent tool hooks fire for "view"
81+
var viewPre, viewPost []hookEntry
82+
for _, h := range hookLog {
83+
if h.toolName == "view" {
84+
if h.kind == "pre" {
85+
viewPre = append(viewPre, h)
86+
} else {
87+
viewPost = append(viewPost, h)
88+
}
89+
}
90+
}
91+
if len(viewPre) == 0 {
92+
t.Fatal("preToolUse should fire for the sub-agent's 'view' tool call")
93+
}
94+
if len(viewPost) == 0 {
95+
t.Fatal("postToolUse should fire for the sub-agent's 'view' tool call")
96+
}
97+
98+
// input.SessionID distinguishes parent from sub-agent
99+
if viewPre[0].sessionID == taskPre.sessionID {
100+
t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks")
101+
}
102+
})
103+
}

go/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ type AutoModeSwitchHandler func(request AutoModeSwitchRequest, invocation AutoMo
349349

350350
// PreToolUseHookInput is the input for a pre-tool-use hook
351351
type PreToolUseHookInput struct {
352+
SessionID string `json:"sessionId"`
352353
Timestamp int64 `json:"timestamp"`
353354
Cwd string `json:"cwd"`
354355
ToolName string `json:"toolName"`
@@ -369,6 +370,7 @@ type PreToolUseHandler func(input PreToolUseHookInput, invocation HookInvocation
369370

370371
// PostToolUseHookInput is the input for a post-tool-use hook
371372
type PostToolUseHookInput struct {
373+
SessionID string `json:"sessionId"`
372374
Timestamp int64 `json:"timestamp"`
373375
Cwd string `json:"cwd"`
374376
ToolName string `json:"toolName"`
@@ -388,6 +390,7 @@ type PostToolUseHandler func(input PostToolUseHookInput, invocation HookInvocati
388390

389391
// UserPromptSubmittedHookInput is the input for a user-prompt-submitted hook
390392
type UserPromptSubmittedHookInput struct {
393+
SessionID string `json:"sessionId"`
391394
Timestamp int64 `json:"timestamp"`
392395
Cwd string `json:"cwd"`
393396
Prompt string `json:"prompt"`
@@ -405,6 +408,7 @@ type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocat
405408

406409
// SessionStartHookInput is the input for a session-start hook
407410
type SessionStartHookInput struct {
411+
SessionID string `json:"sessionId"`
408412
Timestamp int64 `json:"timestamp"`
409413
Cwd string `json:"cwd"`
410414
Source string `json:"source"` // "startup", "resume", "new"
@@ -422,6 +426,7 @@ type SessionStartHandler func(input SessionStartHookInput, invocation HookInvoca
422426

423427
// SessionEndHookInput is the input for a session-end hook
424428
type SessionEndHookInput struct {
429+
SessionID string `json:"sessionId"`
425430
Timestamp int64 `json:"timestamp"`
426431
Cwd string `json:"cwd"`
427432
Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit"
@@ -441,6 +446,7 @@ type SessionEndHandler func(input SessionEndHookInput, invocation HookInvocation
441446

442447
// ErrorOccurredHookInput is the input for an error-occurred hook
443448
type ErrorOccurredHookInput struct {
449+
SessionID string `json:"sessionId"`
444450
Timestamp int64 `json:"timestamp"`
445451
Cwd string `json:"cwd"`
446452
Error string `json:"error"`

nodejs/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,9 @@ export type AutoModeSwitchHandler = (
933933
* Base interface for all hook inputs
934934
*/
935935
export interface BaseHookInput {
936+
/** The runtime session ID of the session that triggered the hook.
937+
* For sub-agent hooks this differs from `invocation.sessionId`. */
938+
sessionId: string;
936939
timestamp: number;
937940
cwd: string;
938941
}

0 commit comments

Comments
 (0)