Fix hooks and permission dispatch for sub-agent sessions#68
Fix hooks and permission dispatch for sub-agent sessions#68
Conversation
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>
…nHandler 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>
|
@copilot shouldn't subagents ask for the parent session for hooks and permissions? How is .NET SDK doing this? |
There was a problem hiding this comment.
Pull request overview
This PR fixes SDK-side dispatching of hooks.invoke and permission.request for CLI-created sub-agent sessions whose session IDs are not present in the SDK session registry, ensuring tool hooks and permission handlers can still run.
Changes:
- Add internal
CopilotSessionquery methods to detect whether hooks / permission handlers are registered. - Update
RpcHandlerDispatcherto fall back to a registered session with hooks/permission handlers when the incomingsessionIdis unknown (and makehooks.invokea no-op withoutput: nullif none exist). - Add/adjust unit tests to cover fallback and no-fallback behavior for both hooks and permission requests.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java | Adds fallback session selection for unknown sessionId in permission.request and hooks.invoke, plus helper finders. |
| src/main/java/com/github/copilot/sdk/CopilotSession.java | Adds package-private hasHooks() / hasPermissionHandler() helpers used by dispatcher fallback. |
| src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java | Adds tests for fallback/no-op behavior for sub-agent permission and hook invocations. |
Copilot's findings
Comments suppressed due to low confidence (3)
src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java:205
- When falling back for an unknown sessionId, the permission handler is invoked via the chosen fallback CopilotSession, but the PermissionInvocation inside CopilotSession uses that session’s own sessionId. This means sub-agent permission requests will present the parent (fallback) sessionId to the handler, losing the actual request sessionId from the RPC params. Consider plumbing the original sessionId through (e.g., pass it into handlePermissionRequest / PermissionInvocation) so handlers can correctly attribute sub-agent requests.
This issue also appears on line 296 of the same file.
String sessionId = params.get("sessionId").asText();
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);
rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result));
return;
}
src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java:312
- When falling back for an unknown sessionId, the HookInvocation created in CopilotSession uses the fallback session’s sessionId, not the RPC param sessionId. For sub-agent hooks.invoke calls this can misattribute which session triggered onPreToolUse/onPostToolUse. Consider passing the original sessionId into hook handling so HookInvocation reflects the request’s sessionId even when a different session instance provides the handlers.
String sessionId = params.get("sessionId").asText();
String hookType = params.get("hookType").asText();
JsonNode input = params.get("input");
CopilotSession session = sessions.get(sessionId);
if (session == null) {
// 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;
}
src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java:375
- findSessionWithPermissionHandler() has the same non-deterministic “first match” issue as findSessionWithHooks(): if more than one session has a permission handler, ConcurrentHashMap iteration order makes the fallback session unpredictable, which can change allow/deny decisions unexpectedly. Consider requiring a single unambiguous fallback candidate or adding a deterministic selection mechanism.
* If multiple sessions have permission handlers, the first one found is
* returned. In practice, SDK users typically register a single permission
* handler that covers all sub-agent activity.
*
* @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;
}
- Files reviewed: 3/3 changed files
- Comments generated: 1
| * If multiple sessions have hooks registered, the first one found is returned. | ||
| * In practice, SDK users typically register hooks on a single session that | ||
| * covers all sub-agent activity. | ||
| * | ||
| * @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; | ||
| } |
There was a problem hiding this comment.
findSessionWithHooks() returns the “first” session found with hooks, but sessions is a ConcurrentHashMap so iteration order is not deterministic. If multiple sessions register hooks, fallback selection becomes effectively random and could run the wrong hooks. Consider only falling back when exactly one candidate session has hooks (otherwise return an error / null), or introduce a deterministic selection strategy (e.g., track a designated ‘default hooks’ session).
This issue also appears on line 362 of the same file.
All three upstream SDKs (C#, TypeScript, Go) throw/error for unknown session IDs in hooks.invoke and permission.request handlers with no fallback logic. The sub-agent hooks gap exists in all SDK implementations and should be addressed at the CLI/protocol level rather than with SDK heuristics. Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/50bf8907-712c-4792-aeea-219889b1ffa9 Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com>
I checked all three upstream SDKs. They all throw/error for unknown session IDs in
None of the upstream SDKs implement sub-agent hook forwarding. The gap exists across all SDK implementations, meaning the issue should be addressed at the CLI/protocol level (the CLI should route sub-agent hooks using the parent session ID). Reverted the SDK-level fallback in |
|
What is this issue #125 of which you speak, @brunoborges ? |
Typo. It is issue #64 |
|
@copilot analyze the changes in this PR for overlap (and therefore for potential conflict) with the work being done in #70 . Respond with a report that allows a human DRI to assess the factors regarding merging this PR now or after #70 has been merged. Note that #70 is still WIP, but you should be able to get an idea of its blast radius. |
Overlap analysis: this PR (#68) vs. codegen PR (#70) This PR's current state: Zero file changes. The SDK-level fallback for sub-agent hooks was implemented and then reverted in PR #70's blast radius (non-generated):
Overlap: None. PR #70 touches build config, codegen scripts, and generated source under Conflict risk: Zero. The two PRs modify completely disjoint sets of files. Merge order recommendation: Either order is safe. Since this PR currently carries no code changes (it documents the upstream behavior decision), merging it now or after #70 produces the same result with no conflict resolution needed. If the intent is to close issue #64 with the "matches upstream, defer to CLI" resolution, this PR can be merged at any time. |
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/b635280e-2e95-4f3d-a5d9-a261f99c6e74 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
modified: .github/aw/actions-lock.json - Adds new pinned entry for `actions/github-script@v9` (sha `373c709c...`) - Updates `github/gh-aw-actions/setup` pin from `v0.63.1` to `v0.68.3` with new sha modified: .github/workflows/weekly-reference-impl-sync.lock.yml - Regenerated by `gh aw compile` (v0.68.3) from the `.md` source that was hand-edited in PR #77 without recompiling - Compiler upgraded from v0.51.6 to v0.68.3; schema version v1 to v3 - Frontmatter hash updated to reflect current `.md` content - `create_issue` assignee now correctly reflects `copilot-swe-agent` (was `copilot`) - `assign_to_agent` now includes `model: claude-opus-4.6` - Safe-outputs config updated with new tool entries (`create_report_incomplete_issue`, `report_incomplete`, `noop`) - Action pin references updated to match newer gh-aw-actions versions Signed-off-by: Ed Burns <edburns@microsoft.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Introduces a TypeScript-based code generator (scripts/codegen/java.ts) that reads api.schema.json and session-events.schema.json to produce: - Typed session event classes (sealed hierarchy under AbstractSessionEvent) - Typed RPC wrapper classes (ServerRpc, SessionRpc) for JSON-RPC methods - Jackson-annotated records with @JsonCreator, @JsonProperty, @JsonInclude Migrates the hand-written events package to auto-generated types, wires the generated RPC wrappers into CopilotClient and CopilotSession, and adds comprehensive tests including E2E coverage. Also includes: Windows CLI path resolution fixes, shared TestUtil extraction, lazy getRpc() initialization, race condition fix in SessionEventsE2ETest, and a guard preventing agentic sync from modifying src/generated/java/ files. Fix review comments: getRpc() IllegalStateException, UnknownSessionEvent wire type, anyOf heuristic, remove unused var Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/9b8b782c-22ad-450f-885d-2b11d5808a0c Co-authored-by: edburns <75821+edburns@users.noreply.github.com> Revert "Fix review comments: getRpc() IllegalStateException, UnknownSessionEvent wire type, anyOf heuristic, remove unused var" This reverts commit ef1de83. Fix Big Risk 1. `sendExpandedToolResult()` bypasses the typed wrapper (HIGH) scripts/codegen/java.ts - Remove the special-case `anyOf` rule that picked `String` when exactly 2 non-null branches included a string type. Multi-branch `anyOf` now falls through to `Object`, matching the C# reference generator's behavior. src/generated/java/com/github/copilot/sdk/generated/rpc/SessionToolsHandlePendingToolCallParams.java - Change the `result` field type from `String` to `Object` (regenerated output reflecting the `anyOf` rule fix in `java.ts`). src/main/java/com/github/copilot/sdk/CopilotSession.java - Replace the `sendExpandedToolResult(requestId, toolResult)` call with a direct typed-wrapper call: `getRpc().tools.handlePendingToolCall(new SessionToolsHandlePendingToolCallParams(...))`. - Delete the `sendExpandedToolResult()` private method and its Javadoc block, which are no longer needed now that the `result` field accepts `Object`. Signed-off-by: Ed Burns <edburns@microsoft.com> On branch copilot/add-classical-code-gen-workflow-ready-for-review modified: scripts/codegen/java.ts - Put `visible = true` on `SessionEventEvent`. - Add `type` property. on `UnknownSessionEvent`. modified: src/generated/java/com/github/copilot/sdk/generated/SessionEvent.java modified: src/generated/java/com/github/copilot/sdk/generated/UnknownSessionEvent.java - Regenerated. modified: src/main/java/com/github/copilot/sdk/CopilotSession.java - Use Double Check Locked to fix Big Risk #2 2. Lazy `SessionRpc` initialization is not thread-safe (HIGH) modified: src/test/java/com/github/copilot/sdk/ForwardCompatibilityTest.java modified: src/test/java/com/github/copilot/sdk/SessionEventDeserializationTest.java - Refine tests based on changes. Signed-off-by: Ed Burns <edburns@microsoft.com> On branch copilot/add-classical-code-gen-workflow-ready-for-review modified: pom.xml - Add profiles for generating code and updating the schemas from which the code is generated. modified: scripts/codegen/java.ts - Address copilot comment: > requiredSet is computed but never used in renderNestedType(), which makes the generator harder to maintain (and can confuse future changes around nullability/boxing). Remove it or use it to drive required-vs-optional component typing if that’s the intent. Signed-off-by: Ed Burns <edburns@microsoft.com> Add AI code review to update-copilot-dependency workflow
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
modified: scripts/codegen/java.ts
### 18:24 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 167 - 178
> The generator passes required=true when generating container element/value types (List<T>, Map<String, V>). This can produce illegal Java generic types like List<long> / Map<String, boolean> when the schema item type is integer/boolean/number, causing generated code not to compile. Fix by ensuring container element/value types are always boxed (e.g., call schemaTypeToJava(..., false, ...) for items and additionalProperties value schemas, or add a dedicated “box primitives in generics” step).
### 18:29 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 193 - 201
> The generator passes required=true when generating container element/value types (List<T>, Map<String, V>). This can produce illegal Java generic types like List<long> / Map<String, boolean> when the schema item type is integer/boolean/number, causing generated code not to compile. Fix by ensuring container element/value types are always boxed (e.g., call schemaTypeToJava(..., false, ...) for items and additionalProperties value schemas, or add a dedicated “box primitives in generics” step).
### 18:31 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 1258 - 1261
> RpcMapper.INSTANCE is a plain new ObjectMapper() with no modules. Generated RPC DTOs can include OffsetDateTime (your type mapping emits it for format: date-time), and ObjectMapper.valueToTree(...) used by session wrappers can fail without JavaTimeModule registered. Fix by configuring this shared mapper consistently (e.g., register com.fasterxml.jackson.datatype.jsr310.JavaTimeModule, and align any other ObjectMapper features needed by the SDK).
### 18:34 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 1202 - 1207
> This comment is likely incorrect/misleading: Java lambdas can generally target a functional interface whose single abstract method is generic (the compiler can still infer the type parameter from the call site). Consider removing this claim or rephrasing it to a neutral recommendation (e.g., “method reference is typical/clear”) so consumers aren’t discouraged from valid usage.
Copilot suggests:
```typescript
lines.push(` * (e.g., a {@code JsonRpcClient} instance). A method reference is typically the clearest`);
lines.push(` * way to adapt a generic {@code invoke} method to this interface:`);
lines.push(` * <pre>{@code`);
lines.push(` * RpcCaller caller = jsonRpcClient::invoke;`);
lines.push(` * }</pre>`);
```
What can we do about this?
### 18:36 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 848 - 854
> For session-scoped RPC methods with additional params, the generated *Params records still include a sessionId field (because they’re generated directly from the schema), but the wrapper then overwrites sessionId via _p.put("sessionId", this.sessionId). This duplication is confusing for API consumers and makes the param records look “callable” with arbitrary session IDs when they are not. Consider adjusting generation so sessionId is omitted from session-scoped params records (and only injected by SessionRpc), or documenting clearly in the generated Javadoc that any provided sessionId is ignored/overridden.
Copilot suggests:
```typescript
* Return the wrapper-visible parameter property names for a method.
* For session-scoped wrappers, sessionId is injected by SessionRpc and is not
* considered a user-supplied parameter.
*/
function wrapperParamPropertyNames(method: RpcMethodNode, isSession: boolean): string[] {
if (!method.params || typeof method.params !== "object") return [];
const props = method.params.properties ?? {};
return Object.keys(props).filter((k) => !(isSession && k === "sessionId"));
}
/**
* Return the params class name if the method has wrapper-visible properties
* (i.e. user-supplied parameters after filtering out injected sessionId for
* session-scoped wrappers).
*/
function wrapperParamsClassName(method: RpcMethodNode, isSession: boolean): string | null {
const userProps = wrapperParamPropertyNames(method, isSession);
```
What can we do about this?
### 18:40 Prompt
Consider these Copilot review comments, all about `java.ts`
- Lines 903 - 915
> For session-scoped RPC methods with additional params, the generated *Params records still include a sessionId field (because they’re generated directly from the schema), but the wrapper then overwrites sessionId via _p.put("sessionId", this.sessionId). This duplication is confusing for API consumers and makes the param records look “callable” with arbitrary session IDs when they are not. Consider adjusting generation so sessionId is omitted from session-scoped params records (and only injected by SessionRpc), or documenting clearly in the generated Javadoc that any provided sessionId is ignored/overridden.
What can we do about this? This seems the same as the previous comment. No?
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/RpcCaller.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/RpcMapper.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionAgentApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionCommandsApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionExtensionsApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionFleetApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionHistoryApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionMcpApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionModeApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionModelApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionPermissionsApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionPlanApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionRpc.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionShellApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionSkillsApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionToolsApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionUiApi.java
modified: src/generated/java/com/github/copilot/sdk/generated/rpc/SessionWorkspaceApi.java
- Regenerated.
Signed-off-by: Ed Burns <edburns@microsoft.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
modified: scripts/codegen/java.ts modified: src/generated/java/com/github/copilot/sdk/generated/rpc/RpcMapper.java Clean run. The fix was: RpcMapper now constructs its own ObjectMapper with the same configuration as JsonRpcClient's mapper, instead of trying to call JsonRpcClient.getObjectMapper() across packages (which failed because JsonRpcClient is package-private). Signed-off-by: Ed Burns <edburns@microsoft.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/51184227-fc7a-4a39-9032-b9b3da932569 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/bb9210b4-2aa3-475c-9c4f-5b2318a436f5 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/0c4c84ed-1cb5-4e79-954f-633c61e99a87 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/b7de171b-6625-4ab4-b222-4858b0cacfc2 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
…est-thread wakeup mid-dispatch Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/e7c09bc7-4fc9-4619-a0d9-d1bed96104b9 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
…ilitiesOverride, agent skills, per-request headers Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/514da8aa-3336-46ca-b39a-48faabcbb354 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/c48ff7e5-7e42-43e6-a3df-42fe0004aa91 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
When the CLI sends hooks.invoke or permission.request with a session ID that is not registered (e.g. for CLI-created sub-agent sessions), fall back to a registered session that has the appropriate handler. If no session with a handler exists, hooks returns null output (no-op) and permissions returns denied. Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/7e1546a6-1f1c-4d86-b564-5d98d7586407 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Rebased to HEAD of
|
|
The work on this PR has been overtaken by other PRs to main. I will ask the agent to start over with a fresh PR, referencing this one for history. |
Resolves #64
Before the change?
hooks.invokeandpermission.requestRPC handlers look up sessions strictly by ID. Sub-agent sessions are created internally by the CLI with their own session IDs that are never registered in the SDK's session map. This causeshandleHooksInvoketo return a-32602error andhandlePermissionRequestto returndenied, silently swallowing the hook/permission call for any tool the sub-agent invokes.After the change?
When the CLI sends
hooks.invokeorpermission.requestwith a session ID that is not in the SDK's session registry (e.g. for CLI-created sub-agent sessions), the dispatcher now falls back to a registered session that has the appropriate handler:handleHooksInvoke: Falls back to a session with hooks registered. If no session has hooks, returns null output (no-op) instead of a-32602error.handlePermissionRequest: Falls back to a session with a permission handler. If no session has a handler, returnsdeniedas before.Added
hasHooksHandler()andhasPermissionHandler()package-private query methods toCopilotSessionto support the fallback lookup.Added
findSessionWithHooks()andfindSessionWithPermissionHandler()helper methods toRpcHandlerDispatcherthat iterate over registered sessions to find a suitable fallback.Added unit tests for both fallback scenarios (sub-agent session ID routes to parent session's handler) and no-fallback scenarios (no registered session has the handler).
Pull request checklist
mvn spotless:applyhas been run to format the codemvn clean verifypasses locallyDoes this introduce a breaking change?