Skip to content

Add classical code generation workflow for typed event and RPC classes#79

Closed
edburns wants to merge 7 commits intomainfrom
copilot/add-classical-code-gen-workflow-ready-for-review
Closed

Add classical code generation workflow for typed event and RPC classes#79
edburns wants to merge 7 commits intomainfrom
copilot/add-classical-code-gen-workflow-ready-for-review

Conversation

@edburns
Copy link
Copy Markdown
Collaborator

@edburns edburns commented Apr 17, 2026

Fixes #69
Supersedes #70

Reviewer's Guide — PR #79

The Big Idea

This PR introduces a TypeScript-based code generator (scripts/codegen/java.ts) that reads the Copilot CLI's JSON schema files (api.schema.json, session-events.schema.json) and produces strongly-typed Java source files under src/generated/java/. The generated code replaces two categories of hand-written code:

  1. Session event classes — A sealed SessionEvent hierarchy (~70 event types) with Jackson @JsonSubTypes polymorphic deserialization, replacing the hand-written com.github.copilot.sdk.events package that is deleted in this PR.

  2. Typed RPC wrapper classesServerRpc and SessionRpc with namespace sub-APIs (e.g., session.tools, session.ui, session.model), plus ~130 param/result record DTOs. These replace raw rpc.invoke("method.name", Map.of(...), ...) calls throughout CopilotClient and CopilotSession.

After this PR, internal RPC call sites in CopilotSession (tool results, permission handling, command handling, elicitation, model switching, logging) use typed wrappers like:

getRpc().tools.handlePendingToolCall(new SessionToolsHandlePendingToolCallParams(...))
getRpc().permissions.handlePendingPermissionRequest(...)
getRpc().ui.elicitation(new SessionUiElicitationParams(...))
getRpc().model.switchTo(new SessionModelSwitchToParams(...))

Both CopilotClient.getRpc() and CopilotSession.getRpc() are public API, so SDK consumers can call any RPC method the CLI exposes — not just those the SDK wraps with convenience methods.

Supporting infrastructure

  • CI workflow codegen-check.yml — Runs the generator and fails if the committed files diverge from what the generator produces.
  • Dependency update workflow update-copilot-dependency.yml — Manual workflow_dispatch that bumps @github/copilot in scripts/codegen/package.json, regenerates all files, and opens a PR.
  • Prohibition guard — The agentic merge workflow (agentic-merge-reference-impl.prompt.md) now includes an ABSOLUTE PROHIBITION preventing the sync agent from hand-editing anything under src/generated/java/.

The Big Risks

1. sendExpandedToolResult() bypasses the typed wrapper (HIGH)

File: CopilotSession.java, line ~871

The generated SessionToolsHandlePendingToolCallParams.result field is typed as String (the code generator picks String for anyOf[string, object]), but the protocol requires a JSON object (ToolResultObject) for success results. The workaround is sendExpandedToolResult(), which hand-builds an ObjectNode and calls rpc.invoke(...) directly.

What to verify: This is the single place where the typed wrapper is intentionally bypassed. If the protocol ever changes the shape of the result field, this hand-built node will silently produce invalid payloads. Confirm the ObjectNode construction (sessionId, requestId, result as serialized tree) matches what the server expects. Also consider whether the generator's anyOf resolution rule should be improved in a follow-up.

2. Lazy SessionRpc initialization is not thread-safe (HIGH)

File: CopilotSession.java, lines ~300–310

sessionRpc is declared volatile, but the lazy init in getRpc() does a check-then-assign without synchronization:

SessionRpc current = sessionRpc;
if (current == null) {
    sessionRpc = current = new SessionRpc(rpc::invoke, sessionId);
}
return current;

Two threads calling getRpc() simultaneously could both see null and each create a separate SessionRpc instance. Because SessionRpc is stateless (captures only caller + sessionId), duplicating it is harmless — but confirm no state is accumulated on the instance.

Additionally, setActiveSessionId() sets sessionRpc = null to force re-creation on the next getRpc() call. This is safe only because SessionRpc doesn't cache anything mutable.

3. ~200 generated files committed as-is — spot-check against schema (MEDIUM)

The src/generated/java/ tree contains ~70 event classes and ~130 RPC param/result/API classes. They are produced by scripts/codegen/java.ts and committed verbatim. The CI codegen-check.yml workflow verifies they stay in sync with the generator, but does not verify the generator is correct relative to the schema.

What to verify: Spot-check 2–3 generated API classes (e.g., SessionToolsApi.java, SessionUiApi.java) against the corresponding methods in api.schema.json. Confirm the parameter record fields match the JSON schema properties (names, types, required vs optional). The generator output is 100% deterministic, so checking a few representative classes is sufficient.

Fixed in https://github.com/github/copilot-sdk-java/tree/copilot/add-classical-code-gen-workflow-ready-add-copilot-review-to-codegen-step

4. Enum fromValue() is case-sensitive (MEDIUM)

File: SessionUiElicitationResult.java (and all generated enums)

Every generated enum has a @JsonCreator fromValue(String) method that does an exact v.value.equals(value) comparison. If the server ever sends "Accept" or "ACCEPT" instead of "accept", deserialization will throw IllegalArgumentException.

PENDING(edburns): See https://github.slack.com/archives/C09TX0PUJ3Z/p1776456451925629

What to verify: Confirm the Copilot CLI consistently sends lowercase enum values. The comparison in CopilotSession uses == on enum constants (e.g., resp.action() == SessionUiElicitationResultAction.ACCEPT), which is correct — the risk is entirely in the deserialization path.


Gotchas

ServerRpc is eager; SessionRpc is lazy — by design

ServerRpc is created once, eagerly, in the Connection record when CopilotClient.start() connects. SessionRpc is created lazily on first CopilotSession.getRpc() call because the sessionId isn't known until createSession() returns. This asymmetry is intentional, not accidental.

Generated RPC wrappers inject sessionId via ObjectNode mutation

Every method in SessionRpc and its sub-API classes serializes the param record to an ObjectNode, then calls _p.put("sessionId", this.sessionId) to inject the session ID. This means the sessionId field in param records (e.g., SessionToolsHandlePendingToolCallParams.sessionId) is effectively overwritten. Call sites still pass sessionId in the constructor for readability, but the generated wrapper stomps it.

Tests now fail instead of skip when CLI is absent

The old pattern was if (cliPath == null) { return; } which silently skipped. The new TestUtil uses assertNotNull(cliPath, ...) which fails. This is intentional — CI must have the CLI installed.

The eventsgenerated package move changes all import paths

Every test and production file that referenced com.github.copilot.sdk.events.SomethingEvent now imports com.github.copilot.sdk.generated.SomethingEvent. The SessionEvent sealed class (which was AbstractSessionEvent in the old package) is now SessionEvent — a class rename, not just a move.

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.
@edburns edburns self-assigned this Apr 17, 2026
@edburns edburns marked this pull request as ready for review April 17, 2026 18:00
Copilot AI review requested due to automatic review settings April 17, 2026 18:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Introduces a TypeScript-based code generation workflow that emits strongly-typed Java session event classes and RPC wrappers from Copilot CLI JSON schemas, replacing the hand-written events package and moving SDK internals to typed RPC calls.

Changes:

  • Add scripts/codegen/java.ts + npm package to generate src/generated/java/ event and RPC classes.
  • Update SDK runtime to deserialize events via generated SessionEvent and to invoke RPC methods through typed ServerRpc/SessionRpc.
  • Add CI workflows to verify generated sources and to automate @github/copilot schema dependency bumps.
Show a summary per file
File Description
src/site/markdown/advanced.md Updates documentation links and examples to use generated event types (SessionEvent, SessionModelChangeEvent).
src/main/java/com/github/copilot/sdk/package-info.java Updates package docs to reference the new generated package for events.
src/main/java/com/github/copilot/sdk/json/SessionConfig.java Changes pre-session event hook type from AbstractSessionEvent to generated SessionEvent.
src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java Changes resume-session event hook type from AbstractSessionEvent to generated SessionEvent.
src/main/java/com/github/copilot/sdk/events/package-info.java Removes legacy hand-written events package documentation (replaced by generated events).
src/main/java/com/github/copilot/sdk/events/UserMessageEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/UnknownSessionEvent.java Deletes legacy unknown-event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ToolUserRequestedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ToolExecutionStartEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ToolExecutionProgressEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ToolExecutionPartialResultEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SystemMessageEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SubagentStartedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SubagentSelectedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SubagentFailedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SubagentDeselectedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SubagentCompletedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SkillInvokedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionUsageInfoEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionTruncationEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionTaskCompleteEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionSnapshotRewindEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionShutdownEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionInfoEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionIdleEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionHandoffEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionEventParser.java Removes legacy custom event parser; switches to Jackson polymorphic deserialization on generated SessionEvent.
src/main/java/com/github/copilot/sdk/events/SessionErrorEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionContextChangedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionCompactionStartEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/SessionCompactionCompleteEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/PendingMessagesModifiedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/HookStartEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/HookEndEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantTurnStartEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantTurnEndEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantStreamingDeltaEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantReasoningEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantReasoningDeltaEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantMessageDeltaEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AssistantIntentEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java Removes legacy sealed base type in favor of generated SessionEvent.
src/main/java/com/github/copilot/sdk/events/AbortEvent.java Deletes legacy hand-written event DTO (replaced by generated equivalent).
src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java Switches server-to-client event parsing to SessionEvent deserialization.
src/main/java/com/github/copilot/sdk/EventErrorHandler.java Updates error handler signature to accept generated SessionEvent.
src/main/java/com/github/copilot/sdk/CopilotSession.java Adds session-level typed RPC accessor, updates dispatch types to SessionEvent, and migrates many RPC calls to typed wrappers.
src/main/java/com/github/copilot/sdk/CopilotClient.java Adds server-level typed RPC accessor backed by generated ServerRpc.
scripts/codegen/package.json Adds Node/TS codegen package definition and dependencies.
scripts/codegen/java.ts Adds the TypeScript generator for Java event classes, RPC DTO records, and RPC wrapper APIs.
scripts/codegen/.gitignore Ignores codegen node_modules/.
pom.xml Adds src/generated/java as a source root and excludes generated sources from Spotless formatting.
jbang-example.java Updates example imports to the generated event package.
docs/WORKFLOWS.md Documents new Codegen Check and dependency bump workflows.
config/spotbugs/spotbugs-exclude.xml Updates SpotBugs exclusions to cover generated DTOs.
config/checkstyle/checkstyle.xml Excludes generated sources from Checkstyle enforcement.
README.md Updates getting-started snippet imports to generated event types.
.github/workflows/update-copilot-dependency.yml Adds workflow to bump @github/copilot, re-run codegen, and open a PR.
.github/workflows/codegen-check.yml Adds workflow to ensure generated files are in sync with the generator output.
.github/prompts/coding-agent-merge-reference-impl-instructions.md Adds a “do not edit generated sources” guardrail for sync automation.
.github/prompts/agentic-merge-reference-impl.prompt.md Adds an absolute prohibition against modifying src/generated/java/**.
.github/copilot-instructions.md Updates repo architecture docs to point to generated events package.
.gitattributes Marks generated Java sources as generated and enforces LF endings.

Copilot's findings

Files not reviewed (1)
  • scripts/codegen/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)

scripts/codegen/java.ts:1

  • RpcMapper creates a fresh default ObjectMapper, which will not have the same module/configuration as the SDK’s runtime mapper (notably JavaTimeModule). Any session RPC wrapper method that uses MAPPER.valueToTree(params) can fail at runtime when params contain OffsetDateTime (and potentially other configured types). Generate RpcMapper so it matches the JsonRpcClient ObjectMapper configuration (e.g., register JavaTimeModule and any deserialization/serialization features used elsewhere), or change wrapper generation to avoid re-serializing params via a separate mapper.
/*---------------------------------------------------------------------------------------------
  • Files reviewed: 83/329 changed files
  • Comments generated: 5

Comment thread src/main/java/com/github/copilot/sdk/CopilotSession.java
Comment thread scripts/codegen/java.ts Outdated
Comment thread scripts/codegen/java.ts
Comment thread scripts/codegen/java.ts
Comment thread scripts/codegen/java.ts Outdated
…ent 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>
@edburns
Copy link
Copy Markdown
Collaborator Author

edburns commented Apr 17, 2026

@copilot revert commit ef1de83 .

I have a different approach and will push it under separate cover.

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 17, 2026

@edburns I've opened a new pull request, #80, to work on those changes. Once the pull request is ready, I'll request review from you.

edburns added 5 commits April 17, 2026 14:48
…essionEvent wire type, anyOf heuristic, remove unused var"

This reverts commit ef1de83.
… (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>
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT]: Add classical code-gen to workflow

3 participants