Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2faaaca
Initial plan
Copilot Apr 11, 2026
b1efa7b
Fix onPreToolUse hook not firing for sub-agent tool calls
Copilot Apr 11, 2026
5a8a2b4
Improve Javadoc for findSessionWithHooks and findSessionWithPermissio…
Copilot Apr 11, 2026
1d0bf62
Revert SDK-level fallback to match upstream .NET/Node.js/Go behavior
Copilot Apr 12, 2026
af77a26
On branch edburns/dd-2853206-reference-impl-not-upstream We are track…
edburns Mar 24, 2026
f3f196b
Replace upstream terminology with reference implementation
edburns Apr 16, 2026
1413aa6
Fix [reference-impl-hash] parameter naming in update-changelog.sh
Copilot Apr 16, 2026
55df001
Ignore tilde files
edburns Apr 16, 2026
36de59c
Address very important comment by copilot.
edburns Apr 16, 2026
123bb06
Apply review suggestions: fix assignee, grammar, and refererce-impl typo
Copilot Apr 16, 2026
a512425
Recompile agentic workflow lock file from updated .md source
edburns Apr 17, 2026
3e3ae55
Add classical code generation workflow for typed event and RPC classes
edburns Apr 17, 2026
5d92685
Prevent local IDEs from editing generated code
edburns Apr 17, 2026
0ef029f
Apply Copilot review comments.
edburns Apr 17, 2026
7be5337
Fix compilation failure.
edburns Apr 17, 2026
5f399a1
Initial plan
Copilot Apr 20, 2026
5f1520a
Fix (int) cast incompatibility with boxed Double in Quick Start code
Copilot Apr 20, 2026
3f861bf
Initial plan
Copilot Apr 20, 2026
25bb498
Add comprehensive test coverage for generated RPC code
Copilot Apr 20, 2026
8556e93
Potential fix for pull request finding 'Unread local variable'
edburns Apr 20, 2026
651f843
Move test files to match generated package structure
Copilot Apr 20, 2026
2293b86
Fix POSIXisms: use TestUtil.tempPath() for cross-platform temp paths
Copilot Apr 20, 2026
7de0184
Fix race condition in sendAndWait: use whenCompleteAsync to prevent t…
Copilot Apr 20, 2026
cfe541c
Update JaCoCo coverage badge
edburns Apr 20, 2026
ea206da
docs: update version references to 0.3.0-java-preview.0
github-actions[bot] Apr 21, 2026
0e06c37
[maven-release-plugin] prepare release v0.3.0-java-preview.0
github-actions[bot] Apr 21, 2026
af969e3
[maven-release-plugin] prepare for next development iteration
github-actions[bot] Apr 21, 2026
16e9d61
Initial plan
Copilot Apr 20, 2026
04c32e9
Port reference implementation sync: McpServerConfig types, ModelCapab…
Copilot Apr 20, 2026
b63c33f
Fix limits mapping in setModel and fix import order in ProviderConfig
Copilot Apr 21, 2026
50d42ea
Initial plan
Copilot Apr 11, 2026
ba9c9e0
Fix hooks and permission dispatch for sub-agent sessions
Copilot Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix onPreToolUse hook not firing for sub-agent tool calls
When the CLI creates sub-agent sessions internally, their session IDs
are not in the SDK's session registry. The hooks.invoke and
permission.request handlers now fall back to any registered session
that has the appropriate handler, enabling hooks and permission checks
to fire for sub-agent tool calls.

Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/868c6fed-c57d-4d9f-806c-eca509096672

Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com>
  • Loading branch information
Copilot and brunoborges authored Apr 11, 2026
commit b1efa7b95348da8fd2cf8384867691ecce108e15
25 changes: 25 additions & 0 deletions src/main/java/com/github/copilot/sdk/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,31 @@ void registerHooks(SessionHooks hooks) {
hooksHandler.set(hooks);
}

/**
* Returns whether this session has hooks registered.
* <p>
* Used internally to find a session that can handle hooks invocations for
* sub-agent sessions whose IDs are not directly tracked by the SDK.
*
* @return {@code true} if hooks are registered and at least one handler is set
*/
boolean hasHooks() {
SessionHooks hooks = hooksHandler.get();
return hooks != null && hooks.hasHooks();
}

/**
* Returns whether this session has a permission handler registered.
* <p>
* Used internally to find a session that can handle permission requests for
* sub-agent sessions whose IDs are not directly tracked by the SDK.
*
* @return {@code true} if a permission handler is registered
*/
boolean hasPermissionHandler() {
return permissionHandler.get() != null;
}

/**
* Registers transform callbacks for system message sections.
* <p>
Expand Down
50 changes: 49 additions & 1 deletion src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo
JsonNode permissionRequest = params.get("permissionRequest");

CopilotSession session = sessions.get(sessionId);
if (session == null) {
// The CLI may send permission requests for sub-agent sessions
// whose IDs are not tracked by the SDK. Fall back to any
// registered session that has a permission handler.
session = findSessionWithPermissionHandler();
}
if (session == null) {
var result = new PermissionRequestResult()
.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER);
Expand Down Expand Up @@ -293,7 +299,14 @@ private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode par

CopilotSession session = sessions.get(sessionId);
if (session == null) {
rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId);
// The CLI may send hooks.invoke for sub-agent sessions whose IDs
// are not tracked by the SDK. Fall back to any registered session
// that has hooks so that application-level hooks (e.g. onPreToolUse)
// also fire for sub-agent tool calls.
session = findSessionWithHooks();
}
if (session == null) {
rpc.sendResponse(Long.parseLong(requestId), Collections.singletonMap("output", null));
return;
}

Expand All @@ -318,6 +331,41 @@ private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode par
});
}

/**
* Finds a session that has hooks registered.
* <p>
* When the CLI creates sub-agent sessions internally, their session IDs are not
* in the SDK's session map. This method searches all registered sessions to
* find one with hooks, enabling hook handlers to fire for sub-agent tool calls.
*
* @return a session with hooks, or {@code null} if none found
*/
private CopilotSession findSessionWithHooks() {
for (CopilotSession s : sessions.values()) {
if (s.hasHooks()) {
return s;
}
}
return null;
}

/**
* Finds a session that has a permission handler registered.
* <p>
* Similar to {@link #findSessionWithHooks()}, this enables permission handlers
* to fire for sub-agent sessions whose IDs are not tracked by the SDK.
*
* @return a session with a permission handler, or {@code null} if none found
*/
private CopilotSession findSessionWithPermissionHandler() {
for (CopilotSession s : sessions.values()) {
if (s.hasPermissionHandler()) {
return s;
}
}
return null;
}

/**
* Functional interface for dispatching lifecycle events.
*/
Expand Down
69 changes: 65 additions & 4 deletions src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ void toolCallHandlerFails() throws Exception {
// ===== permission.request tests =====

@Test
void permissionRequestWithUnknownSession() throws Exception {
void permissionRequestWithUnknownSessionAndNoFallback() throws Exception {
// No sessions at all — returns denied
ObjectNode params = MAPPER.createObjectNode();
params.put("sessionId", "nonexistent");
params.putObject("permissionRequest");
Expand All @@ -307,6 +308,24 @@ void permissionRequestWithUnknownSession() throws Exception {
assertEquals("denied-no-approval-rule-and-could-not-request-from-user", result.get("kind").asText());
}

@Test
void permissionRequestFallsBackToSessionWithHandlerForSubAgent() throws Exception {
// Parent session has permission handler; sub-agent session ID not in map.
CopilotSession parent = createSession("parent-session");
parent.registerPermissionHandler((request, invocation) -> CompletableFuture
.completedFuture(new PermissionRequestResult().setKind("allow")));

ObjectNode params = MAPPER.createObjectNode();
params.put("sessionId", "subagent-session-id");
params.putObject("permissionRequest");

invokeHandler("permission.request", "15", params);

JsonNode response = readResponse();
JsonNode result = response.get("result").get("result");
assertEquals("allow", result.get("kind").asText());
}

@Test
void permissionRequestWithHandler() throws Exception {
CopilotSession session = createSession("s1");
Expand Down Expand Up @@ -453,7 +472,8 @@ void userInputRequestHandlerFails() throws Exception {
// ===== hooks.invoke tests =====

@Test
void hooksInvokeWithUnknownSession() throws Exception {
void hooksInvokeWithUnknownSessionAndNoFallback() throws Exception {
// No sessions at all — returns null output (no-op)
ObjectNode params = MAPPER.createObjectNode();
params.put("sessionId", "nonexistent");
params.put("hookType", "preToolUse");
Expand All @@ -462,8 +482,49 @@ void hooksInvokeWithUnknownSession() throws Exception {
invokeHandler("hooks.invoke", "30", params);

JsonNode response = readResponse();
assertNotNull(response.get("error"));
assertEquals(-32602, response.get("error").get("code").asInt());
JsonNode output = response.get("result").get("output");
assertTrue(output == null || output.isNull(),
"Output should be null when no sessions with hooks are registered");
}

@Test
void hooksInvokeFallsBackToSessionWithHooksForSubAgent() throws Exception {
// Parent session has hooks; sub-agent session ID is not in the map.
// The dispatcher should fall back to the parent session's hooks.
CopilotSession parent = createSession("parent-session");
parent.registerHooks(new SessionHooks().setOnPreToolUse(
(input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.allow())));

ObjectNode params = MAPPER.createObjectNode();
params.put("sessionId", "subagent-session-id");
params.put("hookType", "preToolUse");
ObjectNode input = params.putObject("input");
input.put("toolName", "glob");
input.put("toolCallId", "tc-sub");

invokeHandler("hooks.invoke", "35", params);

JsonNode response = readResponse();
JsonNode output = response.get("result").get("output");
assertNotNull(output, "Hooks should fire for sub-agent tool calls via fallback");
assertEquals("allow", output.get("permissionDecision").asText());
}

@Test
void hooksInvokeFallbackSkipsSessionWithoutHooks() throws Exception {
// Session exists but has no hooks — should not be used as fallback
createSession("no-hooks-session");

ObjectNode params = MAPPER.createObjectNode();
params.put("sessionId", "subagent-session-id");
params.put("hookType", "preToolUse");
params.putObject("input");

invokeHandler("hooks.invoke", "36", params);

JsonNode response = readResponse();
JsonNode output = response.get("result").get("output");
assertTrue(output == null || output.isNull(), "Should return null when no session with hooks is found");
}

@Test
Expand Down
Loading