From cd461cdd19cf41e431f0003c321e27f74370fc59 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 18 Jun 2026 18:19:25 -0400 Subject: [PATCH 01/29] Resume 1682 iterating --- .../1682-low-level-tool-definition.md | 94 +++ .../20260615-prompts.md | 613 ++++++++++++++++++ .../20260616-prompts.md | 218 +++++++ .../20260617-prompts.md | 374 +++++++++++ ...that-use-low_level_tool_definition.yaml.md | 151 +++++ ...rance-reduction-for-implementation-plan.md | 410 ++++++++++++ 6 files changed, 1860 insertions(+) create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/1682-low-level-tool-definition.md create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260617-prompts.md create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/add-tests-that-use-low_level_tool_definition.yaml.md create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/1682-low-level-tool-definition.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/1682-low-level-tool-definition.md new file mode 100644 index 000000000..34fb2ee91 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/1682-low-level-tool-definition.md @@ -0,0 +1,94 @@ +# Plan: Add E2E test for non-ergonomic (low-level) tool definition + +## Goal + +Add a failsafe IT test that exercises the **current explicit** `ToolDefinition.create()` / `ToolDefinition.createOverride()` API — the "non-ergonomic" approach — with multiple tools, `ToolSet` with `addCustom`/`addBuiltIn`, `getArgumentsAs()` deserialization into a record, and a tool handler that mutates application state. This establishes baseline test coverage before issue #1682 adds the annotation-driven ergonomic API. + +## Instructions + +Read `java.instructions.md` in my User level Copilot instructions. This session is about Java. + +Use the `new-java-e2e-test-yaml-and-test` skill to create a new failsafe IT test that exercises the non-ergonomic-tool-definition approach to tool definition. + +### What the test must exercise + +The test class should be `LowLevelToolDefinitionIT.java` in `java/src/test/java/com/github/copilot/`. It must demonstrate **all** of the following in a single session: + +1. **`ToolDefinition.create(name, description, schema, handler)`** — define at least two custom tools explicitly with `Map` schemas. +2. **`ToolDefinition.createOverride(name, description, schema, handler)`** — define one tool that overrides a built-in tool. +3. **`invocation.getArgumentsAs(SomeRecord.class)`** — at least one handler must deserialize arguments into a Java record (not `getArguments()` returning raw Map). +4. **`invocation.getArguments()`** — at least one handler must use the raw `Map` accessor. +5. **`ToolSet` with `addCustom("*").addBuiltIn("web_fetch")`** — pass `setAvailableTools(...)` on the `SessionConfig`. +6. **Handler mutates state** — one tool handler should mutate a field on the test class and the test should assert that the field was updated after the response. +7. **Handler returns `CompletableFuture.completedFuture(...)`** — all handlers return completed futures (as is the current pattern). + +### Concrete test design + +#### Snapshot category + +`tools` (reuse the existing category under `test/snapshots/tools/`). + +#### Snapshot file + +`test/snapshots/tools/low_level_tool_definition.yaml` + +#### Java test method name + +`lowLevelToolDefinition` (converts to `low_level_tool_definition` for snapshot lookup). + +#### Tool definitions for the test + +| Tool | Factory | Name | Description | Schema | Handler behavior | +|------|---------|------|-------------|--------|-----------------| +| Set Phase | `create` | `set_current_phase` | "Sets the current phase of the agent" | `{ type: object, properties: { phase: { type: string, enum: [searching, analyzing, done] } }, required: [phase] }` | Deserializes via `getArgumentsAs(PhaseArgs.class)` where `record PhaseArgs(String phase) {}`. Mutates a `currentPhase` field on the test. Returns `"Phase set to " + phase`. | +| Search | `create` | `search_items` | "Search for items by keyword" | `{ type: object, properties: { keyword: { type: string } }, required: [keyword] }` | Uses `getArguments()` raw Map. Returns a fixed string like `"Found: item_alpha, item_beta"`. | +| Override grep | `createOverride` | `grep` | "Custom grep override" | `{ type: object, properties: { query: { type: string } }, required: [query] }` | Uses `getArguments()`. Returns `"CUSTOM_GREP: " + query`. | + +#### Prompt + +``` +First, set the current phase to 'analyzing'. Then search for items with keyword 'copilot'. Report the phase and search results. +``` + +#### YAML snapshot structure + +Two conversations (one for the tool-call turn, one for the final response turn after tool results are provided): + +- **Conversation 1** (tool call turn): system `${system}` + user prompt → assistant with `tool_calls` for `set_current_phase` and `search_items`. +- **Conversation 2** (final response turn): full history including tool results → assistant final content mentioning "analyzing", "item_alpha", "item_beta". + +Study the existing snapshot files in `test/snapshots/tools/` carefully. In particular, study the snapshot file for the `testInvokesCustomTool` test in `ToolsTest.java` (`test/snapshots/tools/invokes_custom_tool.yaml`). It shows how tool call and tool result conversations are structured. Additionally, study `test/snapshots/tools/should_execute_multiple_custom_tools_in_parallel_single_turn.yaml` which shows multiple parallel tool calls in a single turn. + +#### Assertions + +1. `response` is not null. +2. Response content contains `"analyzing"` (confirming the phase tool was called). +3. Response content contains `"item_alpha"` or `"item_beta"` (confirming search tool was called). +4. The `currentPhase` field on the test class equals `"analyzing"` (confirming handler mutated state). + +#### Session config + +```java +new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of(setPhaseTool, searchTool, grepOverrideTool)) +``` + +### Step-by-step execution + +1. Create the YAML snapshot file at `test/snapshots/tools/low_level_tool_definition.yaml`. +2. Create the Java IT file at `java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java`. +3. Run `mvn spotless:apply` from the `java/` directory (using the background + log pattern from `java.instructions.md`). +4. Run the test in isolation: + ```sh + cd java + LOG="$(date +%Y%m%d-%H%M)-job-logs.txt" && mvn failsafe:integration-test -Dit.test="LowLevelToolDefinitionIT#lowLevelToolDefinition" -Denforcer.skip=true > "$LOG" 2>&1 & tail -f "$LOG" + ``` +5. Fix any failures. Iterate until the isolated test passes cleanly. +6. Run the full build: + ```sh + cd java + LOG="$(date +%Y%m%d-%H%M)-job-logs.txt" && mvn clean verify > "$LOG" 2>&1 & tail -f "$LOG" + ``` +7. Fix any failures from the full build. Iterate until `mvn clean verify` passes cleanly. diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md new file mode 100644 index 000000000..6d2f3ec1a --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md @@ -0,0 +1,613 @@ +### 18:28 Prompt + +Consider this C# usage of our API: + +```C# + [DisplayName("set_current_phase")] + [Description("Sets the current phase of the agent. Use this to report progress.")] + private void SetCurrentPhase(Phase phase) + { + Phase = phase; + updateUi(); + } + +... + + Session = await client.CreateSessionAsync(new() + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = systemMessageConfig, + AvailableTools = new ToolSet().AddCustom("*").AddBuiltIn("web_fetch"), + Tools = [ + CopilotTool.DefineTool(SetCurrentPhase), + CopilotTool.DefineTool(ReportIntent, new() { OverridesBuiltInTool = true }), + CopilotTool.DefineTool(database.SearchProperties), + ], + }); +``` + +How would one do this with the Java API? + +### 18:30 Response + +Searched for regex `public static ToolDefinition create` (`**/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java`), 4 results + +Searched for regex `class ToolSet|addCustom|addBuiltIn|AddCustom|AddBuiltIn` (`**/java/src/main/java/com/github/copilot/rpc/ToolSet.java`), 8 results + +Searched for regex `setAvailableTools|setTools|List` (`**/java/src/main/java/com/github/copilot/rpc/SessionConfig.java`), 5 results + +Here's the Java equivalent: + +```java +var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(systemMessageConfig) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of( + ToolDefinition.create("set_current_phase", "Sets the current phase of the agent. Use this to report progress.", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", "enum", List.of("searching", "analyzing", "done"))), + "required", List.of("phase")), + invocation -> { + Phase phase = invocation.getArgumentsAs(PhaseArgs.class).phase(); + this.phase = phase; + updateUi(); + return CompletableFuture.completedFuture("Phase set to " + phase); + }), + ToolDefinition.createOverride("report_intent", "Reports the agent's intent", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> { /* ... */ }), + ToolDefinition.create("search_properties", "Search property listings", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> database.searchProperties(invocation.getArguments())) + ))).get(30, TimeUnit.SECONDS); +``` + +The key differences from the C# version: + +| C# | Java | +|---|---| +| `CopilotTool.DefineTool(SetCurrentPhase)` — infers name, description, and schema from the method's attributes via reflection | `ToolDefinition.create(name, description, schema, handler)` — all explicit | +| `new() { OverridesBuiltInTool = true }` (options object) | `ToolDefinition.createOverride(...)` (dedicated factory) | +| `[DisplayName]` / `[Description]` attributes on the method | No annotation-based discovery; you provide the strings directly | +| Implicit JSON Schema generation from parameter types | You supply the `Map` schema manually | + +The Java SDK does not currently have a reflection/annotation-based `DefineTool` that can inspect a method and auto-generate the name, description, and parameter schema. Everything is explicit. + +### 18:31 Prompt + +Oof. That is very ugly. So you're saying there is no `DefineTool` analog in Java? + +### 18:34 Response + +Searched for regex `defineTool|DefineTool|@Tool|@CopilotTool|annotation.*tool|reflect.*tool|schema.*generat` (`**/java/src/main/java/**`), no results + +Correct — there is no `DefineTool` analog in the Java SDK today. No reflection-based discovery, no annotations, no automatic JSON Schema generation from parameter types. The only API is `ToolDefinition.create(name, description, schema, handler)` where you supply everything manually. + +The C# version gets its ergonomics from `CopilotTool.DefineTool(MethodGroup)` which uses .NET reflection to pull `[DisplayName]`, `[Description]`, and parameter types to auto-generate the schema. The Go SDK has `DefineTool[T, U](name, desc, handler)` which uses generics to derive the schema from the input type `T`. Python has `@define_tool` which introspects the decorated function's signature and docstring. + +Java *could* support something similar — e.g. a `DefineTool.from(instance, "methodName")` that reflects on the method's parameters and annotations, or a generic approach using a record type for args (similar to Go). But that doesn't exist yet. + +### 18:34 Prompt + +Given that `ToolDefinition` is already a record, run with that fact and sketch out what a `DefineTool` analog might look like. + +### 18:38 Response + +Here's a sketch leveraging records as the args type (similar to Go's `DefineTool[T, U]`): + +```java +// ─── User defines args as a record ─────────────────────────────────────────── + +record WeatherArgs( + @Param("City name") String location, + @Param(value = "Temperature unit", required = false) String unit +) {} + +// ─── Usage: one-liner tool definition ──────────────────────────────────────── + +var tool = ToolDefinition.define("get_weather", "Get current weather for a location", + WeatherArgs.class, + (args, invocation) -> { + String weather = fetchWeather(args.location(), args.unit()); + return CompletableFuture.completedFuture(weather); + }); +``` + +The handler receives the **deserialized record** directly — no more `invocation.getArgumentsAs(...)` boilerplate. + +--- + +### Implementation sketch + +```java +// ─── New annotation ────────────────────────────────────────────────────────── + +package com.github.copilot.rpc; + +import java.lang.annotation.*; + +@Target(ElementType.RECORD_COMPONENT) +@Retention(RetentionPolicy.RUNTIME) +public @interface Param { + /** Description of this parameter. */ + String value(); + /** Whether this parameter is required. Default true. */ + boolean required() default true; +} + +// ─── New typed handler functional interface ────────────────────────────────── + +@FunctionalInterface +public interface TypedToolHandler { + CompletableFuture handle(T args, ToolInvocation invocation); +} + +// ─── New factory method on ToolDefinition ──────────────────────────────────── + +public record ToolDefinition(...) { + + // Existing factories... + + /** + * Defines a tool with automatic JSON Schema generation from a record type. + * + *

Record components become the schema's {@code properties}. Use + * {@link Param} to annotate descriptions and optional flags. + * + * @param the args record type + * @param name tool name + * @param description tool description + * @param argsType the record class (e.g. {@code WeatherArgs.class}) + * @param handler receives the deserialized record + invocation context + * @return a fully constructed ToolDefinition with auto-generated schema + */ + public static ToolDefinition define( + String name, + String description, + Class argsType, + TypedToolHandler handler) { + + Map schema = SchemaGenerator.fromRecord(argsType); + + ToolHandler rawHandler = invocation -> { + T args = invocation.getArgumentsAs(argsType); + return handler.handle(args, invocation); + }; + + return new ToolDefinition(name, description, schema, rawHandler, null, null, null); + } +} +``` + +--- + +### Schema generation from record components + +```java +package com.github.copilot.rpc; + +import java.lang.reflect.RecordComponent; +import java.util.*; + +final class SchemaGenerator { + + static Map fromRecord(Class type) { + RecordComponent[] components = type.getRecordComponents(); + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (RecordComponent comp : components) { + Map prop = new LinkedHashMap<>(); + prop.put("type", jsonType(comp.getType())); + + Param param = comp.getAnnotation(Param.class); + if (param != null) { + prop.put("description", param.value()); + if (param.required()) { + required.add(comp.getName()); + } + } else { + // No annotation → required by default + required.add(comp.getName()); + } + + properties.put(comp.getName(), prop); + } + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + if (!required.isEmpty()) { + schema.put("required", required); + } + return schema; + } + + private static String jsonType(Class type) { + if (type == String.class) return "string"; + if (type == int.class || type == Integer.class + || type == long.class || type == Long.class) return "integer"; + if (type == double.class || type == Double.class + || type == float.class || type == Float.class) return "number"; + if (type == boolean.class || type == Boolean.class) return "boolean"; + if (type.isArray() || List.class.isAssignableFrom(type)) return "array"; + return "object"; + } +} +``` + +--- + +### The BRK206 example rewritten + +```java +record PhaseArgs(@Param("The phase to transition to") Phase phase) {} + +record SearchArgs(@Param("Search query") String query, + @Param(value = "Max results", required = false) Integer limit) {} + +var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(systemMessageConfig) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of( + ToolDefinition.define("set_current_phase", + "Sets the current phase of the agent. Use this to report progress.", + PhaseArgs.class, + (args, inv) -> { + this.phase = args.phase(); + updateUi(); + return CompletableFuture.completedFuture("Phase set to " + args.phase()); + }), + ToolDefinition.define("report_intent", + "Reports the agent's intent", + ReportIntentArgs.class, + (args, inv) -> { /* ... */ }) + .withOverridesBuiltInTool(true), // would need a wither + ToolDefinition.define("search_properties", + "Search property listings", + SearchArgs.class, + (args, inv) -> database.searchProperties(args.query(), args.limit())) + ))).get(30, TimeUnit.SECONDS); +``` + +--- + +### Trade-offs + +| Pro | Con | +|-----|-----| +| Schema auto-generated from record components | Requires runtime reflection (one-time, at definition) | +| Type-safe handler — no manual `getArgumentsAs()` | Nested/complex schemas (arrays of objects) need more work | +| `@Param` keeps description co-located with the field | New annotation + new interface = new public API surface | +| Records are already idiomatic for value objects in Java 17+ | Enums in records (like `Phase`) need custom mapping logic | +| Very close to Go's `DefineTool[T, U]` ergonomics | Can't infer name/description from the method itself (unlike C#) | + +The main limitation vs C# is that Java doesn't have method-group-to-delegate conversion, so you can't just pass `this::setCurrentPhase` and have it figure out the name and schema. You still provide the name and description strings — but the schema becomes free. + +### 18:41 Prompt + +I have added the folder `langchain4j-00-main` to this VS Code workspace. + +Look at the design of `./langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java`. Explain how that design differs from the record based one you proposed. + +### 18:46 Response + +Searched for files matching `**/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java`, 1 match + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java#1-1), lines 1 to 50 + +Searched for text `@interface P` (`**/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java`), 1 result + +Searched for regex `class ToolSpecification|record ToolSpecification|String name|String description|JsonObjectSchema` (`**/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecification.java`), 14 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java#50-50), lines 50 to 60 + +Searched for regex `toolSpecificationFrom|toolSpecificationsFrom|class ToolSpecifications` (`**/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java`), 6 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java#1-1), lines 1 to 60 + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java#100-100), lines 100 to 160 + +Here's how the two designs differ: + +--- + +### LangChain4j: **Annotation-on-method** approach + +```java +@Tool("Get current weather for a location") +String getWeather(@P("City name") String location, + @P(value = "Unit", required = false) String unit) { + return fetchWeather(location, unit); +} +``` + +The framework discovers tools by scanning an **object's methods** for `@Tool`. It then: +1. Reflects on the `Method` to get the name (from `@Tool(name=...)` or the method name itself) +2. Reflects on each `Parameter` to build the JSON Schema (using `@P` for description/required) +3. Generates a `ToolSpecification` (name + description + schema) automatically +4. At invocation time, dispatches to the annotated method with deserialized args passed as method parameters + +**The user never writes a schema or a handler lambda** — the method IS the handler. + +--- + +### My record-based proposal: **Record-as-schema + explicit lambda** approach + +```java +record WeatherArgs(@Param("City name") String location, + @Param(value = "Unit", required = false) String unit) {} + +var tool = ToolDefinition.define("get_weather", "Get current weather", + WeatherArgs.class, + (args, invocation) -> CompletableFuture.completedFuture(fetchWeather(args.location(), args.unit()))); +``` + +The record defines the schema shape; the lambda is the handler. + +--- + +### Key differences + +| Aspect | LangChain4j (`@Tool` on method) | Record-based proposal | +|--------|--------------------------------|----------------------| +| **Discovery** | Automatic — scan object for `@Tool` methods | Manual — you explicitly call `define(...)` | +| **Tool name** | Derived from method name or `@Tool(name=...)` | Provided as a string argument | +| **Description** | `@Tool("description")` on the method | Provided as a string argument | +| **Parameter schema** | Generated from method parameter types + `@P` annotations | Generated from record components + `@Param` annotations | +| **Handler** | The annotated method itself — no lambda | Explicit `TypedToolHandler` lambda | +| **Arg delivery** | Each parameter is a separate method argument | Single deserialized record object | +| **Return type** | Any (String sent as-is, others serialized to JSON) | Always `CompletableFuture` | +| **Registration** | Implicit (pass object to framework, it finds all `@Tool` methods) | Explicit (build a `List`) | +| **Async** | Synchronous method (framework handles threading) | Explicitly async via `CompletableFuture` | + +--- + + + +### The fundamental philosophical difference + +LangChain4j treats **the method as the tool**. You annotate existing methods and the framework does everything else. This is closest to C#'s `CopilotTool.DefineTool(SetCurrentPhase)`. + +My record-based proposal treats **the record as the schema** but still requires explicit wiring. It's closer to Go's `DefineTool[T, U]` — you get schema generation for free but still wire things manually. + +If you wanted the LangChain4j-style ergonomics in the Copilot Java SDK, it would look something like: + +```java +class MyTools { + @CopilotTool("Sets the current phase of the agent") + CompletableFuture setCurrentPhase(@Param("The phase") Phase phase) { + this.phase = phase; + updateUi(); + return CompletableFuture.completedFuture("Done"); + } +} + +// Registration: scan an object for @CopilotTool methods +var tools = ToolDefinition.fromObject(myToolsInstance); +``` + +That's the most ergonomic option but requires the most framework machinery (method scanning, reflective invocation, argument marshaling per-parameter rather than per-record). + +### 18:46 Prompt + +Yes, I like the langchain4j approach much better. Let's start by filing an issue in `copilot-sdk`. + +Title + +Java: Ergonomics: Defining tools + +Description + +The current way of defining tools in Java is quite awful. + + +```java +var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(systemMessageConfig) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of( + ToolDefinition.create("set_current_phase", "Sets the current phase of the agent. Use this to report progress.", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", "enum", List.of("searching", "analyzing", "done"))), + "required", List.of("phase")), + invocation -> { + Phase phase = invocation.getArgumentsAs(PhaseArgs.class).phase(); + this.phase = phase; + updateUi(); + return CompletableFuture.completedFuture("Phase set to " + phase); + }), + ToolDefinition.createOverride("report_intent", "Reports the agent's intent", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> { /* ... */ }), + ToolDefinition.create("search_properties", "Search property listings", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> database.searchProperties(invocation.getArguments())) + ))).get(30, TimeUnit.SECONDS); +``` + +Compare that with what you get in langchain4j: + +```java +@Tool("Get current weather for a location") +String getWeather(@P("City name") String location, + @P(value = "Unit", required = false) String unit) { + return fetchWeather(location, unit); +} +``` + +We simply must do better. + + + +### 18:51 Response + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "Java: Ergonomics: Defining tools" \ + --body 'The current way of defining tools in Java is quite awful. + +```java +var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(systemMessageConfig) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of( + ToolDefinition.create("set_current_phase", "Sets the current phase of the agent. Use this to report progress.", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", "enum", List.of("searching", "analyzing", "done"))), + "required", List.of("phase")), + invocation -> { + Phase phase = invocation.getArgumentsAs(PhaseArgs.class).phase(); + this.phase = phase; + updateUi(); + return CompletableFuture.completedFuture("Phase set to " + phase); + }), + ToolDefinition.createOverride("report_intent", "Reports the agent'''s intent", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> { /* ... */ }), + ToolDefinition.create("search_properties", "Search property listings", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> database.searchProperties(invocation.getArguments())) + ))).get(30, TimeUnit.SECONDS); +``` + +Compare that with what you get in [langchain4j](https://github.com/langchain4j/langchain4j): + +```java +@Tool("Get current weather for a location") +String getWeather(@P("City name") String location, + @P(value = "Unit", required = false) String unit) { + return fetchWeather(location, unit); +} +``` + +We simply must do better.' + +Filed: [#1682](https://github.com/github/copilot-sdk/issues/1682) — Java: Ergonomics: Defining tools. + +### 18:52 Prompt + +Define a new ADR in `java/docs/adr/adr-005-tool-definition.md`. Look at the existing ADRs in there, and follow ADR best practice. Create the new ADR describing the approaches we evaluated and how we ended up choosing the langchain4j approach as shown in https://github.com/langchain4j/langchain4j/blob/main/docs/docs/tutorials/tools.md#high-level-tool-api . + +Be explicit about how we looked at the record approach, the current approach, and chose the langchain4j approach for its Java idiomatic qualities. + +### 19:02 Response + +Searched for files matching `**/java/docs/adr/*.md`, 4 matches + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-004-copilotexperimental.md#1-1), lines 1 to 80 + +Now let me fetch the langchain4j tools tutorial for reference: + + + +Fetched https://github.com/langchain4j/langchain4j/blob/main/docs/docs/tutorials/tools.md + +Now I have all the context. Let me create the ADR: + +Created [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md) + +Created adr-005-tool-definition.md. It documents all three options evaluated (status quo, record-as-schema, annotation-on-method), the rationale for choosing the langchain4j-style approach, and links to the issue and reference implementations. + +Made changes. + + +### 19:02 Prompt + + + +Is there a way around this? + +----- +- Build requirement: recommend `-parameters` javac flag in documentation. Without it, developers must use `@Param(name="...")` explicitly (same trade-off as langchain4j, Spring, etc.). +----- + +We did an automatic annotation processor for `@CopilotExperimental`. Can't we do something similar here? + +### 19:06 Response + +Yes — this is completely solvable with an annotation processor. + +The key insight: **`javax.lang.model.element.VariableElement.getSimpleName()` always returns the real parameter name at compile time**, regardless of whether `-parameters` is passed to `javac`. The `-parameters` flag only controls whether those names survive into the `.class` bytecode for runtime reflection. An annotation processor sees the source-level names unconditionally. + +### How it would work + +The processor runs at compile time, finds all `@CopilotTool`-annotated methods, and generates a metadata class (or resource) that bakes in the parameter names, types, and descriptions: + +```java +// GENERATED — do not edit +final class MyTools$$CopilotToolMeta { + static List definitions(MyTools instance) { + return List.of( + new ToolDefinition("set_current_phase", + "Sets the current phase of the agent.", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", "description", "The phase to transition to")), + "required", List.of("phase")), + invocation -> { + Phase phase = invocation.getArgumentsAs(Phase.class); + return CompletableFuture.completedFuture( + instance.setCurrentPhase(phase)); + }, null, null, null) + ); + } +} +``` + +At runtime, `ToolDefinition.fromObject(myTools)` just loads the generated `$$CopilotToolMeta` class — zero reflection, zero dependency on `-parameters`. + +### Precedent in the ecosystem + +| Framework | Approach | +|-----------|----------| +| **Micronaut** | Annotation processor generates all DI metadata at compile time — no runtime reflection, no `-parameters` needed | +| **Dagger 2** | Processor generates `_Factory` / `_MembersInjector` classes | +| **MapStruct** | Processor generates mapper implementations from interface method signatures | +| **Our own `@CopilotExperimental`** | Processor walks declared elements via JSR 269 | + +### Advantages over the `-parameters` fallback + +1. **No build configuration requirement** — works out of the box with any `javac` invocation. +2. **No runtime reflection** — the generated code calls the method directly. Faster, GraalVM-native-friendly, no `setAccessible` issues. +3. **Compile-time validation** — the processor can emit errors if `@Param` is missing on a parameter, if a type isn't serializable, if two tools have the same name, etc. (same as we did for `@CopilotExperimental`). +4. **Schema correctness guaranteed** — the schema is computed once from the source AST, not guessed at runtime from erased generics. + +### Trade-off vs. pure runtime reflection (langchain4j default) + +| | Annotation processor | Runtime reflection | +|---|---|---| +| Requires `-parameters`? | No | Yes (or `@P(name=...)`) | +| GraalVM native-image friendly? | Yes | Needs reflection config | +| Compile-time error checking? | Yes | Fails at runtime | +| Extra generated source files? | Yes | None | +| Works without running the processor? | No — but fails loudly at compile time | Yes (degraded) | + +So yes: we can follow the same JSR 269 pattern as `@CopilotExperimental` to eliminate the `-parameters` requirement entirely. The processor sees the real names from source, bakes them into generated code, and the runtime never needs to call `Parameter.getName()`. + + + +Want me to update the ADR to reflect this? + +### 19:07 Prompt + +Yes please. + +### 19:14 Response + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md#195-195), lines 195 to 220 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md#218-218), lines 218 to 240 + +Replacing 15 lines with 10 lines in [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md) + + +``` + +``` +Done. Added the `@CopilotExperimental` decision as a consequence bullet — the new annotations and utility methods will be gated behind experimental opt-in until the API stabilizes. + +Made changes. diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md new file mode 100644 index 000000000..931f02d08 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md @@ -0,0 +1,218 @@ +# DAY: 2026-06-16 + +### 19:31 Prompt + +Consider this C# usage of our API: + +```C# + [DisplayName("set_current_phase")] + [Description("Sets the current phase of the agent. Use this to report progress.")] + private void SetCurrentPhase(Phase phase) + { + Phase = phase; + updateUi(); + } + +... + + Session = await client.CreateSessionAsync(new() + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = systemMessageConfig, + AvailableTools = new ToolSet().AddCustom("*").AddBuiltIn("web_fetch"), + Tools = [ + CopilotTool.DefineTool(SetCurrentPhase), + CopilotTool.DefineTool(ReportIntent, new() { OverridesBuiltInTool = true }), + CopilotTool.DefineTool(database.SearchProperties), + ], + }); +``` + +According to our chat yesterday, here's how you'd do it with our Java API. For discussion call this the non-ergonomic-tool-definition. + +```java +var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(systemMessageConfig) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")) + .setTools(List.of( + ToolDefinition.create("set_current_phase", "Sets the current phase of the agent. Use this to report progress.", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", "enum", List.of("searching", "analyzing", "done"))), + "required", List.of("phase")), + invocation -> { + Phase phase = invocation.getArgumentsAs(PhaseArgs.class).phase(); + this.phase = phase; + updateUi(); + return CompletableFuture.completedFuture("Phase set to " + phase); + }), + ToolDefinition.createOverride("report_intent", "Reports the agent's intent", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> { /* ... */ }), + ToolDefinition.create("search_properties", "Search property listings", + Map.of("type", "object", "properties", Map.of(/* ... */)), + invocation -> database.searchProperties(invocation.getArguments())) + ))).get(30, TimeUnit.SECONDS); +``` + +Also according to our chat yesterday: + +----- + +The key differences from the C# version: + +| C# | Java | +|---|---| +| `CopilotTool.DefineTool(SetCurrentPhase)` — infers name, description, and schema from the method's attributes via reflection | `ToolDefinition.create(name, description, schema, handler)` — all explicit | +| `new() { OverridesBuiltInTool = true }` (options object) | `ToolDefinition.createOverride(...)` (dedicated factory) | +| `[DisplayName]` / `[Description]` attributes on the method | No annotation-based discovery; you provide the strings directly | +| Implicit JSON Schema generation from parameter types | You supply the `Map` schema manually | + +The Java SDK does not currently have a reflection/annotation-based `DefineTool` that can inspect a method and auto-generate the name, description, and parameter schema. Everything is explicit. + +------ + +We are working on issue https://github.com/github/copilot-sdk/issues/1682 that addresses this gap. + +But first, I want to add test coverage for the way to do it that apparently currently does work. + +Write a plan to `1682-java-tool-ergonomics-prompts-remove-before-merge/1682-low-level-tool-definition.md` to instruct a `copilot --yolo` session to: + +- Read `java.instructions.md` in my User level Copilot instructions. This session is about Java. +- Use the `new-java-e2e-test-yaml-and-test` skill to create a new failsafe IT test that exercises the non-ergonomic-tool-definition approach to tool definition. +- Run the test in isolation until you get a clean run of just that test. +- Run all the automated tests according to the `java.instructions.md` and `mvn clean verify`. + + + +### 19:40 Prompt + +Right now, the most important thing we did in this session was learn how to create a net new snapshot YAML, `test/snapshots/system_message_sections/should_use_replaced_identity_section_in_response.yaml` and use it in a net-new failsafe IT test. + +This is an important skill. + +I've never used skills before. + +Help me package up this knowledge as a reusable skill. + +I guess skills have names, right? Well, let's call this `new-java-e2e-test-yaml-and-test`, using whatever naming convention one uses for skills. + +Explain what you're donig step by step. I have a need for this skill right now. + +### 19:53 Prompt + +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +◐ The user wants to create a reusable skill for creating new E2E test YAML snapshots and corresponding failsafe IT tests. Let me first understand how skills work in this repo - I can see from the environment that there's already a `java-coding-skill` defined. Let me look at how skills are structured. + +● Let me look at how skills are structured in this repo so I can explain and create one for you. + +● Find existing skills (shell) + │ find /home/edburns/workareas/copilot-sdk/.github/skills -type f | sort + └ 4 lines... + +● List directory .github/skills/java-coding-skill + └ 1 file found + +● List directory .github/skills/rust-coding-skill + └ 2 files found + +● Read SKILL.md + │ .github/skills/rust-coding-skill/SKILL.md + └ L1:30 (30 lines read) + +● Read SKILL.md + │ .github/skills/java-coding-skill/SKILL.md + └ L1:10 (10 lines read) + +● Good. Here's what I'm seeing and what I'll do: + + How Skills Work (Step by Step) + + 1. Skills live in .github/skills// — each skill is a directory containing at minimum a SKILL.md file + 2. SKILL.md has YAML frontmatter with name and description — the description tells Copilot when to invoke the skill + 3. The body of SKILL.md is the instruction content — the "knowledge" that gets loaded when the skill is invoked + 4. Optional extra files (like examples.md) can be included for reference material + + What I'll Create + + For your new-e2e-test-yaml-and-test skill, I'll create: + + - .github/skills/new-e2e-test-yaml-and-test/SKILL.md — the main skill with step-by-step instructions on how to create a new YAML snapshot and corresponding failsafe IT test + - .github/skills/new-e2e-test-yaml-and-test/examples.md — the actual example files we created this session as reference + + Let me create it now: + +✗ Create skill directory (shell) + │ mkdir -p /home/edburns/workareas/copilot-sdk/.github/skills/new-e2e-test-yaml-and-test + └ The user rejected this tool call. User feedback: I named it wrong. The skill should be called + `new-java-e2e-test-yaml-and-test` + +● Create skill directory (shell) + │ mkdir -p /home/edburns/workareas/copilot-sdk/.github/skills/new-java-e2e-test-yaml-and-test + └ 1 line... + +● IDE connection lost: Visual Studio Code - Insiders closed + +● Now let me create the main SKILL.md: + +● Create SKILL.md +222 + └ .github/skills/new-java-e2e-test-yaml-and-test/SKILL.md + +● Now let me add the examples file with the actual artifacts from this session: + +● Get example YAML (shell) + │ cat /home/edburns/workareas/copilot-sdk/test/snapshots/system_message_sections/should_use_replaced_identity_section_in + │ _response.yaml + └ 16 lines... + +● All permissions are now enabled. Tool, path, and URL requests will be automatically approved. + +● Get example test (shell) + │ cat /home/edburns/workareas/copilot-sdk/java/src/test/java/com/github/copilot/SystemMessageSectionsIT.java + └ 193 lines... + +● Create examples.md +178 + └ .github/skills/new-java-e2e-test-yaml-and-test/examples.md + +● Done. Here's what I created: + + .github/skills/new-java-e2e-test-yaml-and-test/ + ├── SKILL.md ← Main instructions (when/how to create new E2E tests) + └── examples.md ← Real examples from this session (simple + tool-call) + + How it works: + + - The description in the frontmatter tells Copilot to invoke this skill whenever you're creating a new Java E2E integration test with a YAML snapshot + - SKILL.md covers: YAML format, proxy matching logic, the Java test template, common pitfalls, and the key constraint that you must handcraft YAMLs (can't record via Java) + - examples.md has two working examples — the simple Botanica test and the multi-turn tool-call test + + The skill is ready to use. Next time you (or Copilot) need to add a new E2E test, the skill will be available as new-java-e2e-test-yaml-and-test. + +### 19:54 Prompt + +Ok, now, in a subsequent session, I will want to use the new skill `new-java-e2e-test-yaml-and-test`. + +How do I instruct the agent to use this skill to do its job? + +Is it something like + +`Use the skill new-java-e2e-test-yaml-and-test to create a new test and yaml that exercises the feature we just created.` + +Or something else? + +### 19:56 Response + + Based on the documentation, here's how skills work: + + You don't need to explicitly invoke skills. Skills are automatically loaded based on their description field in the frontmatter. The agent sees the skill listed in and invokes it when the task matches the description. + + However, you can be explicit if you want. Any of these would work: + + 1. Implicit (recommended): Just describe what you want — the skill's description matches "creating a new Java E2E integration test that requires a new replay proxy YAML snapshot": + Create a new failsafe IT test with a YAML snapshot that exercises the new feature. + 2. Explicit mention: Reference the skill by name to make sure it's invoked: + Use the new-java-e2e-test-yaml-and-test skill to create a test and YAML snapshot for the new feature. + 3. Check available skills: Use /skills in the CLI to see what's loaded, or /env to see the full environment including skills. + + Both approaches work. The agent sees the skill in its available tools and will call it before generating code. The explicit mention is a good safety net if you want to be sure. + diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260617-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260617-prompts.md new file mode 100644 index 000000000..8926ad586 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260617-prompts.md @@ -0,0 +1,374 @@ +## dd-3018003-implement-tools + +Review the chat history in `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md`. + +Most important is the content within the XML element `` + +Also important is the content within the XML element `` + +Use `gh` to open and read and understand any issue or PR links to https://github.com/github/copilot-sdk. + +Read and understand the ADR in `java/docs/adr/adr-005-tool-definition.md`. + +We're not yet ready to ask you to write a `copilot --yolo` ready plan to do the work. We need to break it down better. + +Write a markdown file to `1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` that is a human-targeted plan for how to proceed with the implementation of the `@CopilotTool` ergonomics improvement. + +- At a high level, I know we'll need to define the + + - `@CopilotTool` annotation, with `@CopilotExperimental`. + + - The annotation processor that makes the `@CopilotTool` annotation work. + + - Some way to "register" a tool. A Java idiomatic way of this in C# + + ```C# + CopilotTool.DefineTool(SetCurrentPhase), + CopilotTool.DefineTool(ReportIntent, new() { OverridesBuiltInTool = true }), + CopilotTool.DefineTool(database.SearchProperties), + ``` + + - **MAYBE** something like this: + + ``` + // Registration: scan an object for @CopilotTool methods + var tools = ToolDefinition.fromObject(myToolsInstance); + ``` + + - I say maybe, because I'm not exactly sure how LangChain4J does it. Also, I've been told that micronaut also has a similar thing. I have added the folders `langchain4j-00-main` and `micronaut-core-00` to this VS Code project for reference. + +Break the work down into phases. Note, we've already done these first two phases. + +1. ✅ Define the problem and make an architectural decision, recorded in an ADR. + +2. ✅ Verify the existing way works in Java. (This work is proceeding but as the human DRI, I am confident it will complete successfully.) + +### 13:41 Response + +Updated todo list + +Let me gather all the necessary context first. + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md#1-1), lines 1 to 200 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md#100-100), lines 100 to 400 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-005-tool-definition.md#200-200), lines 200 to 236 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260615-prompts.md#400-400), lines 400 to 600 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/20260616-prompts.md#100-100), lines 100 to 200 + +Now let me look at how LangChain4J and Micronaut handle tool registration to give good guidance in the plan. + +Research LangChain4J and Micronaut tool registration + +Read [](file:///home/edburns/workareas/copilot-sdk/test/snapshots/tools/low_level_tool_definition.yaml) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/add-tests-that-use-low_level_tool_definition.yaml.md) + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/*.java`, 9 matches + +Searched for files matching `micronaut-core-00` · `core-processor/src/main/java/**/*Processor.java`, 1 match + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Searched for files matching `micronaut-core-00` · `core-processor/src/**/*.java`, 30 matches + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java#1-1), lines 1 to 150 + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java#1-1), lines 1 to 100 + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Searched for text `ToolExecutor` (`langchain4j-00-main`), 39 results + +Searched for files matching `micronaut-core-00` · `core-processor/src/main/java/io/micronaut/inject/writer/**`, 20 matches + +Searched for files matching `langchain4j-00-main` · `langchain4j/src/main/java/dev/langchain4j/service/tool/*.java`, 18 matches + +Searched for text `BeanDefinitionWriter` (`micronaut-core-00` · `core-processor/src/main/java/io/micronaut/inject/**`), 49 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/ToolExecutor.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java#1-1), lines 1 to 120 + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject/writer/BeanDefinitionWriter.java#1-1), lines 1 to 150 + +Searched for files matching `micronaut-core-00` · `core-processor/src/main/java/io/micronaut/inject/processing/**/*.java`, 17 matches + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject/visitor/TypeElementVisitor.java#1-1), lines 1 to 80 + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject/visitor/BeanElementVisitor.java) + +Searched for files matching `micronaut-core-00` · `core-processor/src/main/java/io/micronaut/inject/*.java`, no matches + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject/processing/AbstractBeanElementCreator.java) + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject/processing/definition/DefaultElementBeanDefinitionBuilderFactory.java#1-1), lines 1 to 80 + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut) + +Searched for files matching `micronaut-core-00` · `core-processor/src/main/java/io/micronaut/inject/*.java`, no matches + +Read [](file:///home/edburns/workareas/micronaut-core-00/core-processor/src/main/java/io/micronaut/inject) + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java#120-120), lines 120 to 250 + +Searched for text `Processor extends AbstractProcessor` (`micronaut-core-00`), 2 results + +Searched for files matching `micronaut-core-00` · `**/*Processor.java`, 5 matches + +Read [](file:///home/edburns/workareas/micronaut-core-00/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java#1-1), lines 1 to 150 + +Read [](file:///home/edburns/workareas/micronaut-core-00/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java#150-150), lines 150 to 250 + +Created memory file [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/3f1fe06cc8e5cdbce2aa19a5a3bd183d/GitHub.copilot-chat/memory-tool/memories/ZGI5Y2E3ZTMtNDc2My00MTdkLWFjMDEtMDlmY2ViM2M0NTcy/tool-registration-research.md) + +Now let me read the reference prompt file and the existing LowLevelToolDefinitionIT test fully: + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java#100-100), lines 100 to 112 + +Read [](file:///home/edburns/workareas/copilot-sdk/temporary-prompts/add-tests-that-use-should_use_replaced_identity_section_in_response.md) + +Let me look at the existing `@CopilotExperimental` annotation processor for the pattern we'll follow: + +Explore CopilotExperimental processor pattern + +Searched for text `@CopilotExperimental` (`copilot-sdk` · `java/**`), 101 results + +Searched for files matching `copilot-sdk` · `java/**/CopilotExperimental.java`, 1 match + +Searched for files matching `copilot-sdk` · `java/**/AbstractProcessor.java`, no matches + +Searched for files matching `copilot-sdk` · `java/**/Processor.java`, no matches + +Searched for files matching `copilot-sdk` · `java/**/ToolDefinition.java`, 1 match + +Searched for files matching `copilot-sdk` · `java/**/ToolHandler.java`, 1 match + +Searched for files matching `copilot-sdk` · `java/**/ToolInvocation.java`, 1 match + +Searched for files matching `copilot-sdk` · `java/**/ToolSet.java`, 1 match + +Searched for text `AbstractProcessor` (`copilot-sdk` · `java/**`), 4 results + +Searched for text `process(Set annotations` (`copilot-sdk` · `java/**`), 1 result + +Searched for text `Processor` (`copilot-sdk` · `java/**/META-INF/services/**`), 2 results + +Read [](file:///home/edburns/workareas/copilot-sdk/java/pom.xml#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/CopilotExperimental.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/CopilotExperimentalProcessor.java#1-1), lines 1 to 150 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolHandler.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java#1-1), lines 1 to 80 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolSet.java#1-1), lines 1 to 100 + +Searched for files matching `copilot-sdk` · `java/**/AllowCopilotExperimental.java`, 1 match + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/CopilotExperimentalProcessor.java#140-140), lines 140 to 165 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/pom.xml#100-100), lines 100 to 200 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/docs/adr/adr-004-copilotexperimental.md) + +Read [](file:///home/edburns/workareas/copilot-sdk/java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/pom.xml#1-1), lines 1 to 600 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/AllowCopilotExperimental.java) + +Searched for files matching `copilot-sdk` · `java/src/test/java/**/CopilotExperimentalProcessorTest.java`, 1 match + +Searched for text `CopilotExperimental` (`copilot-sdk` · `java/src/test/**`), 13 results + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java#80-80), lines 80 to 172 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolSet.java#100-100), lines 100 to 123 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/resources/META-INF/services) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java#80-80), lines 80 to 150 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/test/java/com/github/copilot/CopilotExperimentalProcessorTest.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/resources/META-INF/services/javax.annotation.processing.Processor) + +Searched for files matching `copilot-sdk` · `java/**/module-info.java`, 1 match + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/module-info.java) + +Read memory + +Created memory file [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/3f1fe06cc8e5cdbce2aa19a5a3bd183d/GitHub.copilot-chat/memory-tool/memories/ZGI5Y2E3ZTMtNDc2My00MTdkLWFjMDEtMDlmY2ViM2M0NTcy/copilot-experimental-annotation-exploration.md) + +Read [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/3f1fe06cc8e5cdbce2aa19a5a3bd183d/GitHub.copilot-chat/chat-session-resources/db9ca7e3-4763-417d-ac01-09fceb3c4572/toolu_vrtx_01XThiJD8h5cYY6QPnjSbxVy__vscode-1781713597255/content.txt#1-1), lines 1 to 200 + +Now let me look at the specific LangChain4J implementation for reference on schema generation and tool execution: + +LangChain4J tool schema generation details + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java`, 1 match + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java`, 1 match + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java`, 1 match + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/*Executor*.java`, no matches + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/**`, 13 matches + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/Tool.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/P.java) + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/*.java`, 9 matches + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java#224-224), lines 224 to 224 + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonSchema.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonSchemaElement.java) + +Searched for regex `class.*JsonSchemaElementUtils|jsonSchemaElementFrom` (`langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/**`), 10 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolExecutionRequest.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecification.java#1-1), lines 1 to 200 + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.java#1-1), lines 1 to 500 + +Searched for regex `Method\.invoke|coerceArguments|parseArguments|jsonToObjects` (`langchain4j-00-main`), 23 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.java#100-100), lines 100 to 500 + +Searched for regex `execute.*Tool|invoke.*request|class.*Handler.*Tool` (`langchain4j-00-main` · `langchain4j-core/**`), 22 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-skills/src/main/java/dev/langchain4j/skills/AbstractSkillToolExecutor.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonObjectSchema.java#1-1), lines 1 to 200 + +Searched for regex `class.*AiService|AiServiceImpl` (`langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/**`), 28 results + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/service/tool) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/service) + +Searched for files matching `langchain4j-00-main` · `langchain4j-core/src/main/java/dev/langchain4j/**/ToolExecutor.java`, no matches + +No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files. +If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]). +Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true. + +Searched for regex `interface ToolExecutor` (`langchain4j-00-main`), 1 result + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-skills/src/main/java/dev/langchain4j/skills/ActivateSkillToolExecutor.java) + +Searched for regex `.*` (`langchain4j-00-main` · `langchain4j/src/main/java/dev/langchain4j/service/tool/**`), 101 results + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/ToolExecutor.java) + +Searched for regex `class.*ToolInvoker|class.*DefaultToolExecutor|executeToolMethod|invokeToolMethod` (`langchain4j-00-main`), 2 results + +Searched for files matching `langchain4j-00-main` · `langchain4j/src/main/java/dev/langchain4j/service/tool/ToolExecutionResult.java`, 1 match + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/DefaultToolExecutor.java#1-1), lines 1 to 500 + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/ToolExecutionResult.java#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j/src/main/java/dev/langchain4j/service/tool/ToolExecutionRequestUtil.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonArraySchema.java) + +Read [](file:///home/edburns/workareas/langchain4j-00-main/langchain4j-core/src/main/java/dev/langchain4j/model/chat/request/json/JsonEnumSchema.java) + +Created memory file [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/3f1fe06cc8e5cdbce2aa19a5a3bd183d/GitHub.copilot-chat/memory-tool/memories/ZGI5Y2E3ZTMtNDc2My00MTdkLWFjMDEtMDlmY2ViM2M0NTcy/langchain4j-tool-implementation-analysis.md) + +Created 6 todos + +Now I have comprehensive context from all sources. Let me create the plan: + +Created [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md) + +Created dd-3018003-ignorance-reduction-for-implementation-plan.md. + +The plan has 6 phases: + +1. **✅ ADR** — Done (ADR-005). +2. **✅ Low-level path verified** — `LowLevelToolDefinitionIT` passes. +3. **Ignorance reduction** — 8 open questions to resolve before coding (package placement, annotation design, type mapping, generated code shape, `fromObject()` API, module-info, processor registration, access level requirements). +4. **Implementation** — 6 ordered steps: annotations → schema generator → processor → `fromObject()` → E2E test → reflection fallback (optional). +5. **Documentation** — README updates. +6. **Cross-language port** — Evaluate whether the ergonomic API snapshot needs porting (may not — other SDKs have their own ergonomic paths). + +Key architectural decision captured: **langchain4j's API** (annotations on methods) + **Micronaut's implementation strategy** (JSR 269 compile-time code generation, no `-parameters` flag needed). + +Completed: *Write implementation plan for @CopilotTool ergonomics* (1/6) + +Made changes. + diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/add-tests-that-use-low_level_tool_definition.yaml.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/add-tests-that-use-low_level_tool_definition.yaml.md new file mode 100644 index 000000000..b60937cd2 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/add-tests-that-use-low_level_tool_definition.yaml.md @@ -0,0 +1,151 @@ +## Goal + +Port the Java integration test behavior from: + +- `java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java` +- test method: `lowLevelToolDefinition` +- snapshot: `test/snapshots/tools/low_level_tool_definition.yaml` + +to the following non-Java SDKs, using each language's native E2E test infrastructure: + +1. `dotnet` +2. `go` +3. `nodejs` +4. `python` +5. `rust` + +The new/updated tests in each language must use the **same snapshot scenario** (`tools/low_level_tool_definition`) and validate the same behavior. + +--- + +## Required test behavior to port + +From a test perspective, replicate this behavior: + +1. Define a `set_current_phase` tool that accepts a `phase` argument (string, enum: `["searching", "analyzing", "done"]`) and returns `"Phase set to {phase}"`. The tool handler must also store the phase value in test-local state. +2. Define a `search_items` tool that accepts a `keyword` argument (string) and returns `"Found: item_alpha, item_beta"`. +3. Define a `grep` override tool (using whatever "override" mechanism the language provides) that accepts a `query` argument (string) and returns `"CUSTOM_GREP: {query}"`. +4. Create a session with: + - Permission handler that auto-approves all requests. + - Available tools: all custom tools (`*`) plus built-in `web_fetch`. + - The three tool definitions registered on the session. +5. Send prompt: `"First, set the current phase to 'analyzing'. Then search for items with keyword 'copilot'. Report the phase and search results."` +6. Assert: + - The assistant response is non-null/non-empty. + - The response content (case-insensitive) contains `"analyzing"`. + - The response content contains `"item_alpha"` or `"item_beta"`. + - The test-local phase state equals `"analyzing"` (verifying the tool handler was actually invoked). + +Do not weaken these assertions. + +--- + +## Critical execution constraint (must follow exactly) + +Proceed through languages **one-at-a-time** in this exact order: + +1. `dotnet` +2. `go` +3. `nodejs` +4. `python` +5. `rust` + +❌❌ **Do not continue to the next language unless and until the current language gets a clean run with the new test in isolation.** ❌❌ + +Do **not** run full cross-language or full-repo test suites. Let CI/CD handle broad runs. + +--- + +## Snapshot/name mapping requirements + +Ensure each language's test naming/harness maps to: + +- snapshot folder: `tools` +- snapshot file: `low_level_tool_definition.yaml` + +Do not create alternate snapshot names for this scenario. + +--- + +## Per-language isolated run commands + +Use these commands for isolated validation while iterating. + +### 1) dotnet + +Implement in dotnet E2E tests (preferred: new `LowLevelToolDefinitionE2ETests` class or add to existing `ToolsE2ETests` class using snapshot category `tools`, test method `Low_Level_Tool_Definition`). + +Isolated run: + +```bash +cd dotnet && dotnet test test/GitHub.Copilot.SDK.Test.csproj --filter "FullyQualifiedName~Low_Level_Tool_Definition" +``` + +### 2) go + +Implement in Go E2E tests with snapshot mapping to `tools/low_level_tool_definition` (preferred: add to existing `go/internal/e2e/tools_e2e_test.go` or create new file, subtest name exactly `low_level_tool_definition`). + +Isolated run: + +```bash +cd go && go test ./internal/e2e -run 'TestToolsE2E/low_level_tool_definition$' -count=1 +``` + +### 3) nodejs + +Implement in Node E2E Vitest (preferred: add to existing `nodejs/test/e2e/tools.e2e.test.ts` or create new file, test name mapping to `low_level_tool_definition`). + +Isolated run: + +```bash +cd nodejs && npm test -- test/e2e/tools.e2e.test.ts -t "low_level_tool_definition" +``` + +### 4) python + +Implement in Python E2E pytest (preferred: add to existing `python/e2e/test_tools_e2e.py` or create new file, test function `test_low_level_tool_definition`). + +Isolated run: + +```bash +cd python && uv run pytest e2e/test_tools_e2e.py::test_low_level_tool_definition +``` + +### 5) rust + +Implement in Rust E2E tests (preferred: add to existing `rust/tests/e2e/tools.rs`; use `with_e2e_context("tools", "low_level_tool_definition", ...)`). + +Isolated run: + +```bash +cd rust && cargo test --features test-support --test e2e tools::low_level_tool_definition -- --exact +``` + +--- + +## Implementation notes + +1. Reuse existing per-language E2E harness helpers and style conventions. +2. Keep changes scoped to test code and required wiring. +3. Do not hand-edit generated code. +4. ❌❌❌ DO NOT CHANGE ANY non-test CODE.❌❌❌ +5. ✅✅Put the test in the "right place" for each language. That means put it "near" any similar existing tests. The existing tools E2E test files are: + - `dotnet/test/E2E/ToolsE2ETests.cs` + - `go/internal/e2e/tools_e2e_test.go` + - `nodejs/test/e2e/tools.e2e.test.ts` + - `python/e2e/test_tools_e2e.py` + - `rust/tests/e2e/tools.rs` + Put the new test near those. ✅✅ +6. The snapshot `test/snapshots/tools/low_level_tool_definition.yaml` involves **two conversations**: one where tool calls are made without prior tool results, and one full round-trip (tool calls → tool results → final assistant message). Each language's replay proxy handles this; just ensure the test sends the right prompt and processes tool invocations correctly. +7. The `grep` override tool uses whatever "tool override" mechanism exists in each language (e.g., `ToolDefinition.createOverride` in Java, or the equivalent in each SDK). If a language has no override concept, define it as a regular custom tool named `grep`. + +--- + +## Deliverable + +When done, provide: + +1. files changed per language, +2. isolated command used per language, +3. pass/fail result per language (must be passing before moving to next), +4. any blockers (if any language cannot be completed). diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md new file mode 100644 index 000000000..c28f85c7f --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md @@ -0,0 +1,410 @@ +# Implementation plan: `@CopilotTool` ergonomics (issue #1682) + +Human DRI: Ed Burns +ADR: `java/docs/adr/adr-005-tool-definition.md` +Issue: https://github.com/github/copilot-sdk/issues/1682 + +--- + +## Completed phases + +### Phase 1 ✅ — Define the problem and architectural decision + +- ADR-005 evaluates three options (status quo, record-as-schema, annotation-on-method). +- Decision: annotation-on-method with compile-time JSR 269 processor (langchain4j-style API, Micronaut-style implementation). + +### Phase 2 ✅ — Verify the existing low-level path works in Java + +- `test/snapshots/tools/low_level_tool_definition.yaml` created. +- `LowLevelToolDefinitionIT` passes with explicit `ToolDefinition.create()` / `createOverride()`. +- This proves the low-level API is correct and will serve as the foundation that the high-level API delegates to. + +--- + +## Phase 3 — Ignorance reduction: questions to answer before writing code + +This phase is about eliminating unknowns. Each item is a question or spike. Resolve these **before** writing production code. + +### 3.1 — Package placement + +**Question:** Where do `@CopilotTool` and `@Param` live? + +Current SDK structure is a single module (`copilot-sdk-java`). Two options: + +| Option | Location | Trade-off | +|--------|----------|-----------| +| A | `com.github.copilot.rpc` (alongside `ToolDefinition`) | Keeps everything together but the `rpc` package is already dense (40+ classes). | +| B | New package `com.github.copilot.tool` | Cleaner separation; the `tool` package holds annotations, processor, and `ToolDefinition.fromObject()`. But `ToolDefinition` itself stays in `rpc` (it's a JSON-RPC type). | + +**Recommendation:** Option B — new `com.github.copilot.tool` package for annotations + processor + schema generation. `ToolDefinition` stays in `rpc` and gets a new static method `fromObject(Object)` that delegates to `tool` package internals. + +**Action:** Decide; update `module-info.java` exports if new package is added. + +### 3.2 — `@CopilotTool` annotation design + +**Question:** What attributes does `@CopilotTool` need? + +Based on ADR-005 and the C#/langchain4j comparisons: + +```java +@Documented +@Retention(RetentionPolicy.SOURCE) // only needed at compile time for processor +@Target(ElementType.METHOD) +@CopilotExperimental +public @interface CopilotTool { + /** Tool description (sent to the model). */ + String value(); + + /** Tool name. Defaults to method name converted to snake_case. */ + String name() default ""; + + /** Whether this tool overrides a built-in tool. */ + boolean overridesBuiltInTool() default false; + + /** Whether to skip permission checks. */ + boolean skipPermission() default false; +} +``` + +**Open questions:** + +1. Should `@CopilotTool` have `@Retention(SOURCE)` (processor-only, like Dagger) or `RUNTIME` (fallback reflection path, like langchain4j)? ADR-005 says "compile-time preferred, runtime fallback" — if we want a fallback path, we need `RUNTIME`. If we commit to processor-only, `SOURCE` is sufficient. + +2. Is `ToolDefer` (the defer config from `ToolDefinition`) needed on the annotation, or is that too niche for v1? + +**Recommendation:** Start with `RUNTIME` retention so the reflection fallback works. Defer `ToolDefer` support to a follow-up. + +### 3.3 — `@Param` annotation design + +**Question:** What attributes does `@Param` need? + +```java +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Param { + /** Parameter description (sent to the model). */ + String value() default ""; + + /** Parameter name override. Defaults to the actual parameter name. */ + String name() default ""; + + /** Whether this parameter is required. Default true. */ + boolean required() default true; +} +``` + +**Open question:** LangChain4J's `@P` also has `defaultValue()`. Do we need that? Probably not for v1 — the model doesn't use default values; it's for documentation. + +### 3.4 — Type-to-JSON-Schema mapping + +**Question:** What Java types do we need to map to JSON Schema, and how? + +Minimum viable set (from langchain4j's `JsonSchemaElementUtils`): + +| Java type | JSON Schema | +|-----------|-------------| +| `String` | `{"type": "string"}` | +| `int`, `Integer`, `long`, `Long` | `{"type": "integer"}` | +| `double`, `Double`, `float`, `Float` | `{"type": "number"}` | +| `boolean`, `Boolean` | `{"type": "boolean"}` | +| `enum` types | `{"type": "string", "enum": ["V1", "V2", ...]}` | +| `List`, `Collection` | `{"type": "array", "items": }` | +| `Map` | `{"type": "object"}` (opaque — no inner schema) | +| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | + +**Key design decision:** The annotation processor must generate this schema at compile time from `javax.lang.model` types (`TypeMirror`, `DeclaredType`, etc.), NOT from `java.lang.reflect` types. This is different from langchain4j which does it at runtime. + +**Spike needed:** Write a small proof-of-concept that maps `TypeMirror` → JSON Schema `Map` literal in generated source code. The tricky cases are: +- Enum constants (processor can see them via `ElementKind.ENUM_CONSTANT`) +- Generic type arguments on `List` (processor sees them via `DeclaredType.getTypeArguments()`) +- Recursive/nested records (need `$defs` or just go one level deep for v1) + +**Recommendation:** Start with the flat types (primitives, String, enums) and `List`. Defer nested records and polymorphic types to a follow-up. + +### 3.5 — Generated code shape + +**Question:** What exactly does the processor generate? + +ADR-005 proposes: + +```java +// GENERATED — do not edit +final class MyTools$$CopilotToolMeta { + static List definitions(MyTools instance) { + return List.of( + new ToolDefinition("set_current_phase", "Sets the current phase", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", + "description", "The phase to transition to")), + "required", List.of("phase")), + invocation -> { + String phase = (String) invocation.getArguments().get("phase"); + return CompletableFuture.completedFuture( + instance.setCurrentPhase(phase)); + }, null, null, null) + ); + } +} +``` + +**Open questions:** + +1. **Method invocation in generated code:** The generated lambda calls `instance.setCurrentPhase(phase)` directly — no reflection at runtime. But this requires the method to be accessible (not private). What access levels do we support? langchain4j uses `method.setAccessible(true)` at runtime. We'd need to either: + - Require `public` or package-private methods, OR + - Generate a helper that uses `MethodHandles.Lookup` to access private methods (complex), OR + - Just require non-private. + + **Recommendation:** Require at least package-private. The generated `$$CopilotToolMeta` class is in the same package, so package-private and above work. Emit a compile error for `private` methods annotated with `@CopilotTool`. + +2. **Return type handling:** What does the generated code do with the method's return value? + + | Return type | Generated behavior | + |-------------|-------------------| + | `String` | Wrap in `CompletableFuture.completedFuture(result)` | + | `CompletableFuture` | Use as-is (native async) | + | `CompletableFuture` | `.thenApply(objectMapper::writeValueAsString)` | + | `void` | `CompletableFuture.completedFuture("Success")` | + | Other `T` | JSON-serialize via Jackson `ObjectMapper` | + + **Recommendation:** Support `String`, `void`, `CompletableFuture`, and `CompletableFuture` for v1. Other return types get JSON-serialized (since Jackson is already a dependency). + +3. **Argument deserialization in generated code:** How does the generated lambda extract and coerce arguments? + + For simple types, the generated code can cast directly from the `Map` returned by `invocation.getArguments()`: + ```java + String city = (String) invocation.getArguments().get("city"); + int count = ((Number) invocation.getArguments().get("count")).intValue(); + ``` + + For complex types (records, enums), use `invocation.getArgumentsAs()` or Jackson's `ObjectMapper.convertValue()`: + ```java + Phase phase = objectMapper.convertValue(invocation.getArguments().get("phase"), Phase.class); + ``` + + **Recommendation:** Generate direct casts for primitives/String, and `ObjectMapper.convertValue()` for enums, records, and complex types. The `ObjectMapper` instance can come from a static field in the generated class. + +### 3.6 — `ToolDefinition.fromObject(Object)` registration API + +**Question:** How does the user get from "an object with `@CopilotTool` methods" to a `List`? + +```java +// Primary API — loads generated $$CopilotToolMeta class +List tools = ToolDefinition.fromObject(myToolsInstance); + +// Variant: from class (for static tools) +List tools = ToolDefinition.fromClass(MyTools.class); +``` + +**Implementation:** + +```java +public static List fromObject(Object instance) { + Class clazz = instance.getClass(); + String metaClassName = clazz.getName() + "$$CopilotToolMeta"; + try { + Class metaClass = Class.forName(metaClassName); + Method defs = metaClass.getMethod("definitions", clazz); + return (List) defs.invoke(null, instance); + } catch (ClassNotFoundException e) { + // Fallback: runtime reflection (if we support it) + return fromObjectReflective(instance); + } +} +``` + +**Open question:** Do we want the reflection fallback? It's nice for users who don't run the processor (e.g., scripting, prototyping), but it adds code and the `-parameters` concern. + +**Recommendation:** Implement the reflection fallback but mark it `@CopilotExperimental` separately. The primary path is the generated `$$CopilotToolMeta`. + +### 3.7 — `module-info.java` impact + +The SDK uses JPMS. The processor generates classes into the user's module, not the SDK's. But `fromObject()` uses `Class.forName()` which needs the generated class to be accessible. + +**Question:** Does the generated `$$CopilotToolMeta` class in the user's module need to be exported for `fromObject()` to find it? + +**Answer:** No. `Class.forName()` with the caller's classloader works within the same module. And in the typical unnamed-module (classpath) case, everything is accessible. If the user has a named module, the generated class is in the same package as their tools class, so it's accessible. + +**Action:** Verify this works in a simple named-module test. + +### 3.8 — Processor registration + +**Question:** How is the new `@CopilotTool` processor registered alongside `CopilotExperimentalProcessor`? + +The existing `META-INF/services/javax.annotation.processing.Processor` lists `CopilotExperimentalProcessor`. Add the new processor to the same file: + +``` +com.github.copilot.CopilotExperimentalProcessor +com.github.copilot.tool.CopilotToolProcessor +``` + +And in `module-info.java`: +```java +provides javax.annotation.processing.Processor + with CopilotExperimentalProcessor, CopilotToolProcessor; +``` + +**No issues expected here** — this is standard JSR 269 multi-processor registration. + +--- + +## Phase 4 — Implementation (the build order) + +After Phase 3 questions are resolved, implement in this order. Each step should be a separately testable commit. + +### 4.1 — Annotations (`@CopilotTool`, `@Param`) + +**What:** Create the two annotation classes. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/CopilotTool.java` +- `java/src/main/java/com/github/copilot/tool/Param.java` + +**Tests:** +- Compile-only: ensure they compile, can be applied to methods/parameters, and are annotated with `@CopilotExperimental`. +- No runtime behavior yet. + +**Gating criteria:** `mvn clean compile` passes. + +### 4.2 — Schema generation utility (compile-time) + +**What:** A utility class that, given `javax.lang.model` types, produces the `Map` JSON Schema as a Java source code literal. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/SchemaGenerator.java` (compile-time, works with `TypeMirror`) + +**Tests:** +- Unit tests that exercise the type-to-schema mapping with mock `TypeMirror` instances (or integration tests via the annotation processor in a test compilation). + +**Gating criteria:** Can generate correct schema `Map` source code for: `String`, `int`, `boolean`, `double`, `enum`, `List`, a simple record. + +### 4.3 — Annotation processor (`CopilotToolProcessor`) + +**What:** JSR 269 processor that finds `@CopilotTool` methods and generates `$$CopilotToolMeta` classes. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java` + +**Files to modify:** +- `java/src/main/resources/META-INF/services/javax.annotation.processing.Processor` — add the new processor +- `java/src/main/java/module-info.java` — add `provides` clause and `exports com.github.copilot.tool` + +**Tests:** +- **Compilation tests:** Compile test source files with `@CopilotTool` methods and verify: + - `$$CopilotToolMeta` class is generated + - Generated schema matches expected JSON Schema + - Compile errors emitted for: private methods, unsupported parameter types, duplicate tool names +- Use `javax.tools.JavaCompiler` programmatically (same pattern langchain4j uses for testing annotation processors). + +**Gating criteria:** Processor generates correct `$$CopilotToolMeta` for a class with 2-3 `@CopilotTool` methods. + +### 4.4 — `ToolDefinition.fromObject(Object)` + +**What:** The runtime bridge that loads generated metadata and returns `List`. + +**Files to modify:** +- `java/src/main/java/com/github/copilot/rpc/ToolDefinition.java` — add `fromObject(Object)` and `fromClass(Class)` static methods + +**Tests:** +- Unit test: create a test class with `@CopilotTool` methods, compile it (processor generates metadata), call `fromObject()`, verify the returned `List` has correct names, descriptions, schemas, and working handlers. + +**Gating criteria:** `ToolDefinition.fromObject(new MyTestTools())` returns a list with working tool definitions. + +### 4.5 — E2E integration test + +**What:** An E2E failsafe IT that uses `@CopilotTool` + `ToolDefinition.fromObject()` against the replay proxy. + +**Files to create:** +- `test/snapshots/tools/ergonomic_tool_definition.yaml` — new snapshot (may be identical to `low_level_tool_definition.yaml` since the wire format is the same) +- `java/src/test/java/com/github/copilot/ErgonomicToolDefinitionIT.java` + +**The test will look like:** + +```java +class MyTestTools { + String currentPhase; + + @CopilotTool("Sets the current phase of the agent") + String setCurrentPhase(@Param("The phase to transition to") String phase) { + currentPhase = phase; + return "Phase set to " + phase; + } + + @CopilotTool("Search for items by keyword") + String searchItems(@Param("Search keyword") String keyword) { + return "Found: item_alpha, item_beta"; + } + + @CopilotTool(value = "Custom grep override", name = "grep", overridesBuiltInTool = true) + String grepOverride(@Param("Search query") String query) { + return "CUSTOM_GREP: " + query; + } +} + +@Test +void ergonomicToolDefinition() throws Exception { + MyTestTools tools = new MyTestTools(); + List toolDefs = ToolDefinition.fromObject(tools); + + // ... create session with toolDefs, send prompt, assert same behavior + // as LowLevelToolDefinitionIT +} +``` + +**Gating criteria:** Test passes with the same assertions as `LowLevelToolDefinitionIT` — proving the ergonomic API produces identical behavior to the explicit API. + +### 4.6 — Reflection fallback (optional, can defer) + +**What:** `fromObject()` falls back to runtime reflection when `$$CopilotToolMeta` is not found. + +**Files to create/modify:** +- `java/src/main/java/com/github/copilot/tool/ReflectiveToolScanner.java` +- Modify `ToolDefinition.fromObject()` to call this on `ClassNotFoundException` + +**Tests:** +- Compile a test class WITHOUT the annotation processor, call `fromObject()`, verify it still works (with `-parameters` flag). + +**Gating criteria:** Fallback path produces the same `List` as the processor-generated path. + +--- + +## Phase 5 — Documentation and examples + +- Update `java/README.md` with the ergonomic tool definition example. +- Add a "Tools" section showing both the low-level and high-level APIs. +- Reference ADR-005 for design rationale. + +--- + +## Phase 6 — Port to `add-tests-that-use-ergonomic_tool_definition.yaml.md` + +Same cycle as Phase 2 → `add-tests-that-use-low_level_tool_definition.yaml.md`: once the Java E2E test passes with the ergonomic API, create a prompt to port the test to dotnet/go/nodejs/python/rust. + +**Note:** This may not be applicable — the ergonomic API (`@CopilotTool`) is Java-specific. The other SDKs already have their own ergonomic paths. The snapshot can be shared, but the test code is language-specific by nature. Evaluate whether this phase is needed after Phase 5. + +--- + +## Reference: how langchain4j and Micronaut do it + +### langchain4j (runtime reflection) + +- `@Tool` on methods, `@P` on parameters. +- `ToolSpecifications.toolSpecificationsFrom(Object)` scans methods at runtime. +- `JsonSchemaElementUtils` maps `java.lang.reflect.Type` → JSON Schema. +- `DefaultToolExecutor.executeWithContext()` invokes via `Method.invoke()` with argument coercion. +- Requires `-parameters` javac flag or explicit `@P(name="...")`. +- Source: `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/` + +### Micronaut (compile-time annotation processor) + +- `AbstractInjectAnnotationProcessor` (extends `AbstractProcessor`) is the JSR 269 entry point. +- `TypeElementVisitor` SPI pattern: visitors registered via SPI walk the AST. +- `BeanDefinitionWriter` generates bytecode companion classes (`$Definition`, `$Definition$Exec`). +- `ParameterElement.getName()` at compile time — no `-parameters` flag needed. +- Source: `inject-java/src/main/java/io/micronaut/annotation/processing/` + +### Our approach: langchain4j's API + Micronaut's implementation strategy + +- **User-facing API** matches langchain4j: `@CopilotTool` on methods, `@Param` on parameters, `fromObject()` to discover. +- **Implementation** matches Micronaut: JSR 269 processor generates companion classes at compile time, no runtime reflection in the happy path, no `-parameters` requirement. +- **Fallback** path uses langchain4j-style runtime reflection for users who don't run the processor (prototyping, scripting). From edf457c2f8dca8562680b9da4f1b68990c7b0a2a Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 18 Jun 2026 19:42:43 -0400 Subject: [PATCH 02/29] Phase 03 answer questions --- ...gnorance-reduction-for-implementation-plan.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md index c28f85c7f..7f48ccef3 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md @@ -40,6 +40,8 @@ Current SDK structure is a single module (`copilot-sdk-java`). Two options: **Action:** Decide; update `module-info.java` exports if new package is added. +**Resolution:** Select Option B. + ### 3.2 — `@CopilotTool` annotation design **Question:** What attributes does `@CopilotTool` need? @@ -74,6 +76,8 @@ public @interface CopilotTool { **Recommendation:** Start with `RUNTIME` retention so the reflection fallback works. Defer `ToolDefer` support to a follow-up. +**Resolution:** Select `RUNTIME` and `ToolDefer` support. + ### 3.3 — `@Param` annotation design **Question:** What attributes does `@Param` need? @@ -91,10 +95,20 @@ public @interface Param { /** Whether this parameter is required. Default true. */ boolean required() default true; + + /** Optional default value when the argument is omitted. */ + String defaultValue() default ""; } ``` -**Open question:** LangChain4J's `@P` also has `defaultValue()`. Do we need that? Probably not for v1 — the model doesn't use default values; it's for documentation. +**Resolution:** Support `defaultValue()` in v1 (langchain4j parity) and make it behaviorally effective, not docs-only. + +Implementation rules: +- Emit JSON Schema defaults at `properties..default` for model guidance. +- Apply defaults at invocation time when an argument key is missing, then do normal coercion/casting and method invocation. +- Forbid `required=true` together with a non-empty `defaultValue()` (compile-time error in processor, matching runtime reflection fallback validation). +- Parse and validate `defaultValue()` against the Java parameter type (fail fast on mismatch). +- Ensure compile-time generated path and runtime reflection fallback use identical defaulting semantics. ### 3.4 — Type-to-JSON-Schema mapping From 6d42a4311cc01204da83fab7f89c6742e10ab441 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 18 Jun 2026 19:44:40 -0400 Subject: [PATCH 03/29] On branch edburns/1682-java-tool-ergonomics Your branch is up to date with 'upstream/edburns/1682-java-tool-ergonomics'. Changes to be committed: (use "git restore --staged ..." to unstage) new file: 1682-java-tool-ergonomics-prompts-remove-before-merge/20260618-prompts.md Signed-off-by: Ed Burns --- .../20260618-prompts.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260618-prompts.md diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260618-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260618-prompts.md new file mode 100644 index 000000000..03a1b5af8 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260618-prompts.md @@ -0,0 +1,135 @@ +### 18:56 Prompt + +Consider `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md`. + +I am going through the open questions and answering them. + +I am at this one: + +**Open questions:** + +> 1. Should `@CopilotTool` have `@Retention(SOURCE)` (processor-only, like Dagger) or `RUNTIME` (fallback reflection path, like langchain4j)? ADR-005 says "compile-time preferred, runtime fallback" — if we want a fallback path, we need `RUNTIME`. If we commit to processor-only, `SOURCE` is sufficient. +> +> 2. Is `ToolDefer` (the defer config from `ToolDefinition`) needed on the annotation, or is that too niche for v1? +> +> **Recommendation:** Start with `RUNTIME` retention so the reflection fallback works. Defer `ToolDefer` support to a follow-up. + +I need to know what `ToolDefer` is. Make me smart. + +### 19:00 Response + + I’ll trace  ToolDefer  in the SDK and summarize what it does, where it’s used, and why it matters for your annotation design decision. + + ● Search "\bToolDefer\b" 21 lines found + ● Search "defer" in **/*.java (java) 28 lines found + + JV Read ToolDefer.java 69 lines read + JV Read ToolDefinition.java 166 lines read + JV Read ToolDefinitionTest.java 62 lines read + GO Read types.go L1120:1175 (55 lines read) + MD Read README.md L1:260 (155 lines read) + MD Read dd-3018003-ignorance-reduction-for-implementation-plan.md L1:220 (220 lines read) + + ● Search ""defer"|ToolDefer|deferred tools|tool search" (docs, java/docs, java/README.md) No matches found + + MD Read adr-005-tool-definition.md L1:260 (235 lines read) + + ●  ToolDefer  is the per-tool loading policy in  ToolDefinition —it tells Copilot whether to preload a tool definition or allow lazy loading via tool search. + + In this repo it has exactly two values: + + 1.  AUTO  ( "auto" ): tool may be deferred and surfaced through tool search. + 2.  NEVER  ( "never" ): tool is always pre-loaded. + + If unset ( null  / omitted JSON), runtime decides. In Java, this is  com.github.copilot.rpc.ToolDefer  and is wired via  ToolDefinition.createWithDefer(...) ; same concept exists in Go ( Tool.Defer ). + + For your open question: this is an advanced optimization knob (prompt/tool catalog size vs immediate availability), not core ergonomics. So “defer  ToolDefer  support on  @CopilotTool  for v1” is a reasonable cut, as long as low-level  ToolDefinition.createWithDefer(...)  remains available for power users. + + +### 18:59 Prompt + +Now to this one + +> ### 3.3 — `@Param` annotation design + +> **Question:** What attributes does `@Param` need? +> +> ```java +> @Documented +> @Retention(RetentionPolicy.RUNTIME) +> @Target(ElementType.PARAMETER) +> public @interface Param { +> /** Parameter description (sent to the model). */ +> String value() default ""; +> +> /** Parameter name override. Defaults to the actual parameter name. */ +> String name() default ""; +> +> /** Whether this parameter is required. Default true. */ +> boolean required() default true; +> } +> ``` +> +> **Open question:** LangChain4J's `@P` also has `defaultValue()`. Do we need that? Probably not for v1 — the model doesn't use default values; it's for documentation. + +Does the Zod schema that dictates tool definition have a concept of a default value? We can't add one in the Java SDK if it doesn't exist in the lower level Copilot CLI. + +### 19:28 Response + + ● Short answer: you’re not blocked by the lower-level schema. + + The wire-level tool shape treats  parameters  as an open JSON object, not a closed schema with fixed keys: + + • Node RPC type:  Tool.parameters?: { [k: string]: unknown }  ( nodejs/src/generated/rpc.ts ) + • Java generated RPC type:  @JsonProperty("parameters") Map  ( java/src/generated/java/com/github/copilot/generated/rpc/Tool.java ) + • Go type:  Parameters map[string]any  ( go/types.go ) + + So JSON Schema keywords like  default  can flow through. There is just no dedicated first-class  defaultValue  field in  ToolDefinition ; it would have to be emitted into the JSON schema for that parameter ( properties..default ). + + Practically: adding  @Param(defaultValue=...)  is compatible at the transport layer, but likely documentation/hint only unless you also implement SDK/runtime behavior to apply defaults when args are missing. + +### 19:29 Prompt + +Well, of course, if the user specifies a default, then we need to make it so that default does flow through to the tool invocation. Is that implementable? I don't see why not. + +### 19:31 Response + + ● Yes—it’s implementable, and you should do it in the Java SDK layer. + + The CLI/tool wire contract already passes raw argument JSON through; Java can apply defaults before invoking the method. A solid design is: + + 1. Emit  default  into the generated JSON Schema ( properties..default ) for model guidance. + 2. In generated/reflection invocation code, merge defaults into the incoming args map only when a key is absent (not when present with explicit  null , unless you define that behavior). + 3. Then run normal coercion/casting and method invocation. + + Key policy choices to lock down: + + •  required  +  defaultValue : either forbid this combo, or allow it and treat default as satisfying missing input. + • Type parsing:  defaultValue  is annotation text, so processor/reflection path must parse/validate it against param type (fail fast on mismatch). + • Path parity: compile-time generated path and runtime reflection fallback must apply identical defaulting semantics. + + So your instinct is right: no lower-level CLI blocker; it’s a Java API/runtime behavior decision. + +### 19:32 Prompt + +Consider your response. + +> The CLI/tool wire contract already passes raw argument JSON through; Java can apply defaults before invoking the method. A solid design is: +> +> 1. Emit  default  into the generated JSON Schema ( properties..default ) for model guidance. +> 2. In generated/reflection invocation code, merge defaults into the incoming args map only when a key is absent (not when present with explicit  null , unless you define that behavior). +> 3. Then run normal coercion/casting and method invocation. + +> •  required  +  defaultValue : either forbid this combo, or allow it and treat default as satisfying missing input. + +We must forbid this. + +> • Type parsing:  defaultValue  is annotation text, so processor/reflection path must parse/validate it against param type (fail fast on mismatch). + +Yes, we must have this. + +> • Path parity: compile-time generated path and runtime reflection fallback must apply identical defaulting semantics. + +Yes, we must have this. + +Yes, I want to support `defaultValue()` like LangChain4J's `@P`. I am annotating `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` as I go. Capture this decision, and the very useful aspects of your previous response and edit `dd-3018003-ignorance-reduction-for-implementation-plan.md` to state this as the Resolution to question 3.3. From 0751844f041af7091a383ede35ed029782c697e6 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 16:07:43 -0400 Subject: [PATCH 04/29] WIP: Phase 3. Question 3.4 --- .../20260622-prompts.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md new file mode 100644 index 000000000..98815503d --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -0,0 +1,53 @@ +### 15:51 Prompt + +#### Context + +Read `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` to get the context. + +In this session, we are working on "Phase 3 — Ignorance reduction: questions to answer before writing code". + +We have completed and answered up to, but not including, "3.4 — Type-to-JSON-Schema mapping". + +I will proceed after this prompt. + +### 15:57 Response + +Updated todo list + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#1-1), lines 1 to 200 + +Read [](file:///home/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#200-200), lines 200 to 400 + +I've read the full implementation plan. We're at **Phase 3, section 3.4 — Type-to-JSON-Schema mapping**. The remaining Phase 3 items to resolve are: + +- **3.4** — Type-to-JSON-Schema mapping (compile-time `TypeMirror` → JSON Schema) +- **3.5** — Generated code shape (access levels, return types, argument deserialization) +- **3.6** — `ToolDefinition.fromObject(Object)` registration API +- **3.7** — `module-info.java` impact +- **3.8** — Processor registration + +Ready when you are. + +### 15:59 Prompt + +Consider: "3.4 — Type-to-JSON-Schema mapping" + +> **Question:** What Java types do we need to map to JSON Schema, and how? + +> Minimum viable set (from langchain4j's `JsonSchemaElementUtils`): +> +> | Java type | JSON Schema | +> |-----------|-------------| +> | `String` | `{"type": "string"}` | +> | `int`, `Integer`, `long`, `Long` | `{"type": "integer"}` | +> | `double`, `Double`, `float`, `Float` | `{"type": "number"}` | +> | `boolean`, `Boolean` | `{"type": "boolean"}` | +> | `enum` types | `{"type": "string", "enum": ["V1", "V2", ...]}` | +> | `List`, `Collection` | `{"type": "array", "items": }` | +> | `Map` | `{"type": "object"}` (opaque — no inner schema) | +> | Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | +> + +The entire GitHub Copilot SDK for Java is really a JSON RPC wrapper to communicate with the GitHub Copilot CLI. As such, it already has established user expectations for type mappings. The reasonable answer to question 3.4 is: all of the ones mapped elsewhere in the API. + +You suggested a minimum viable set. Looking at the code in packages `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`, produce the maximum viable set in a similar table format. From 9441d486b309fbdb3124783e51db9d7e97eff95c Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 16:30:32 -0400 Subject: [PATCH 05/29] WIP: Phase 3. Question 3.6 --- .../20260622-prompts.md | 301 ++++++++++++++++++ ...rance-reduction-for-implementation-plan.md | 66 ++++ 2 files changed, 367 insertions(+) diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md index 98815503d..294c7fd71 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -51,3 +51,304 @@ Consider: "3.4 — Type-to-JSON-Schema mapping" The entire GitHub Copilot SDK for Java is really a JSON RPC wrapper to communicate with the GitHub Copilot CLI. As such, it already has established user expectations for type mappings. The reasonable answer to question 3.4 is: all of the ones mapped elsewhere in the API. You suggested a minimum viable set. Looking at the code in packages `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`, produce the maximum viable set in a similar table format. + +### 16:09 Response + +Updated todo list + +Catalog Java types in rpc packages + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/generated/java/com/github/copilot/generated/rpc) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java#1-1), lines 1 to 200 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolHandler.java#1-1), lines 1 to 50 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java#1-1), lines 1 to 100 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SessionConfig.java#1-1), lines 1 to 200 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java#200-200), lines 200 to 400 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SessionConfig.java#200-200), lines 200 to 700 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java#100-100), lines 100 to 172 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SessionConfig.java#700-700), lines 700 to 1200 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java#400-400), lines 400 to 600 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SessionConfig.java#1200-1200), lines 1200 to 1700 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java#600-600), lines 600 to 700 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && find java/src/main/java/com/github/copilot/rpc/ -name "*.java" -exec wc -l {} + | sort -n | tail -5 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "private\|public\|protected" java/src/main/java/com/github/copilot/rpc/*.java | grep -E "(private|public|protected)\s+(static\s+)?(final\s+)?\S+\s+\w+" | grep -v "return\|this\|void\|class\|interface\|enum\|record\|package\|import\|@\|//\|/\*\|\*" | grep -oP '(private|public|protected)\s+(static\s+)?(final\s+)?(\S+(<[^>]+>)?)\s+\w+' | grep -oP '(private|public|protected)\s+(static\s+)?(final\s+)?\K\S+(<[^>]+>)?' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "CompletableFuture" java/src/main/java/com/github/copilot/rpc/*.java | grep -oP 'CompletableFuture<[^>]+>' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "Map<" java/src/main/java/com/github/copilot/rpc/*.java | grep -oP 'Map<[^>]+>' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rl "^public enum\|^public record\|^public class\|^public interface\|^public final class\|^public sealed\|^public abstract" java/src/main/java/com/github/copilot/rpc/*.java | xargs grep -l "^public enum" 2>/dev/null + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rl "^public record" java/src/main/java/com/github/copilot/rpc/*.java + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rl "^public interface\|^public @FunctionalInterface\|@FunctionalInterface" java/src/main/java/com/github/copilot/rpc/*.java + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && for f in ProviderConfig NamedProviderConfig ProviderModelConfig ModelInfo ModelCapabilitiesOverride ModelCapabilities ModelSupports ModelLimits ModelVisionLimits ModelBilling ModelPolicy McpServerConfig McpStdioServerConfig McpHttpServerConfig CustomAgentConfig DefaultAgentConfig InfiniteSessionConfig SystemMessageConfig MemoryConfiguration LargeToolOutputConfig TelemetryConfig; do echo "=== $f ==="; grep -n "private\|public record\|public class\|public enum\|public sealed" java/src/main/java/com/github/copilot/rpc/${f}.java 2>/dev/null | head -30; done + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && for f in SessionHooks CommandDefinition CommandContext CommandWireDefinition ElicitationSchema ElicitationContext ElicitationParams ElicitationResult SectionOverride PermissionInvocation PermissionRequest PermissionRequestResult PermissionRequestResultKind UserInputRequest UserInputResponse UserInputInvocation AutoModeSwitchInvocation AutoModeSwitchRequest ExitPlanModeInvocation ExitPlanModeRequest ExitPlanModeResult HookInvocation PreToolUseHookInput PostToolUseHookInput PostToolUseFailureHookInput PreMcpToolCallHookInput SessionStartHookInput SessionEndHookInput Attachment BlobAttachment MessageAttachment InputOptions; do echo "=== $f ==="; grep -n "private\|public record\|public class\|public enum\|public sealed" java/src/main/java/com/github/copilot/rpc/${f}.java 2>/dev/null | head -20; done + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && for f in AzureOptions ModelBillingTokenPrices ToolBinaryResult ToolResultObject ToolSet BuiltInTools SessionCapabilities SessionContext SendMessageRequest SendMessageResponse MessageOptions CreateSessionRequest CreateSessionResponse ResumeSessionRequest ResumeSessionConfig ResumeSessionResponse AgentInfo SessionLifecycleEvent SessionLifecycleEventTypes SessionLifecycleEventMetadata SessionListFilter SessionMetadata GetAuthStatusResponse GetStatusResponse GetModelsResponse GetMessagesResponse PingResponse CloudSessionOptions CloudSessionRepository SessionUiApi SessionUiCapabilities; do echo "=== $f ==="; grep -n "private\|public record\|public class\|public enum\|public sealed\|public interface" java/src/main/java/com/github/copilot/rpc/${f}.java 2>/dev/null | head -30; done + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && for f in PermissionRequestResultKind PostToolUseFailureHookOutput PreMcpToolCallHookOutput PostToolUseHookOutput PreToolUseHookOutput SessionStartHookOutput SessionEndHookOutput UserPromptSubmittedHookInput UserPromptSubmittedHookOutput JsonRpcError JsonRpcRequest JsonRpcResponse ListSessionsResponse DeleteSessionResponse GetForegroundSessionResponse GetLastSessionIdResponse GetSessionMetadataResponse SetForegroundSessionRequest SetForegroundSessionResponse; do echo "=== $f ==="; head -50 java/src/main/java/com/github/copilot/rpc/${f}.java 2>/dev/null | grep -E "private |public record|public class|JsonProperty"; done + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SendMessageResponse.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/SessionUiApi.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/CreateSessionResponse.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ModelBilling.java) + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && find java/src/generated/java/com/github/copilot/generated/rpc/ -name "*.java" | wc -l + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "private\|public record\|public enum\|public class\|public interface\|public sealed" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -E "^\S+:\d+:\s+(private|public)\s+" | grep -oP '(private|public)\s+(static\s+)?(final\s+)?(\S+(<[^>]+>)?)\s+\w+' | grep -oP '(private|public)\s+(static\s+)?(final\s+)?\K\S+(<[^>]+>)?' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "private " java/src/generated/java/com/github/copilot/generated/rpc/*.java | sed 's/^[[:space:]]*//' | grep "^private " | sed 's/private //' | sed 's/ [a-zA-Z_][a-zA-Z0-9_]*;.*//' | sed 's/ [a-zA-Z_][a-zA-Z0-9_]* =.*//' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rPoh '(?<=private\s)\S+(?:\s*<[^>]+>)?' java/src/generated/java/com/github/copilot/generated/rpc/*.java | sort -u + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendParams.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java#1-1), lines 1 to 80 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/generated/java/com/github/copilot/generated/rpc/Model.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/generated/java/com/github/copilot/generated/rpc/ServerRpc.java#1-1), lines 1 to 80 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "^public enum" java/src/generated/java/com/github/copilot/generated/rpc/*.java | sed 's/public enum //' | sed 's/ {.*//' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "^public record\|^public class\|^public interface\|^public sealed\|^public final class" java/src/generated/java/com/github/copilot/generated/rpc/*.java | sed 's/public record //' | sed 's/public class //' | sed 's/public interface //' | sed 's/public sealed interface //' | sed 's/public final class //' | sed 's/(.*//' | sed 's/ {.*//' | sed 's/ extends.*//' | sed 's/ implements.*//' | sort -u + +Read [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/0a47833b2fec2df1980b294571e4f51d/GitHub.copilot-chat/chat-session-resources/e01ff3e8-22ab-4df6-878d-6311894eaf0a/toolu_vrtx_011124MhaCgPR2gvRHwJ4Ndy__vscode-1782158068142/content.txt#1-1), lines 1 to 300 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "^public record" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -oP '\b\w+(?:<[^>]+>)?' | grep -v "^public\|^record\|^@\|^String\|^void\|^JsonProperty\|^JsonInclude\|^JsonIgnoreProperties\|^Generated\|^CopilotExperimental" | sort -u | head -80 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rPoh '(?<=@JsonProperty\("[^"]*"\)\s)\S+(?:<[^>]+>)?' java/src/generated/java/com/github/copilot/generated/rpc/*.java | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "@JsonProperty" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -oP '@JsonProperty\("[^"]*"\)\s+\K\S+' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "Map<" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -oP 'Map<[^>]+>' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "CompletableFuture<" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -oP 'CompletableFuture<[^>]+>' | sort -u + +Read [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/0a47833b2fec2df1980b294571e4f51d/GitHub.copilot-chat/chat-session-resources/e01ff3e8-22ab-4df6-878d-6311894eaf0a/toolu_vrtx_011q19D6rVhGooBNSoryk2X8__vscode-1782158068148/content.txt) + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "import java\.\|import com.fasterxml\|import com.github" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep -v "^import com.github.copilot.generated.rpc\.\|^import com.github.copilot.CopilotExperimental" | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "import java\.\|import com.fasterxml\|import com.github" java/src/main/java/com/github/copilot/rpc/*.java | grep -v "^import com.github.copilot.rpc\.\|^import com.github.copilot.CopilotExperimental\|^import com.github.copilot.SystemMessageMode\|^import com.github.copilot.generated.SessionEvent" | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "sealed\|@JsonSubTypes\|@JsonTypeInfo" java/src/generated/java/com/github/copilot/generated/rpc/*.java | head -30 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rl "sealed" java/src/generated/java/com/github/copilot/generated/rpc/*.java + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "sealed" java/src/main/java/com/github/copilot/rpc/*.java + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -n "private\|public class\|public record" java/src/main/java/com/github/copilot/rpc/SystemMessageSections.java java/src/main/java/com/github/copilot/rpc/SystemPromptSections.java java/src/main/java/com/github/copilot/rpc/SystemMessageConfig.java | head -30 + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "int\|long\|double\|boolean\|float\|byte\|short\|char" java/src/generated/java/com/github/copilot/generated/rpc/*.java | grep "@JsonProperty" | grep -oP '@JsonProperty\("[^"]*"\)\s+\K(int|long|double|boolean|float|byte|short|char)\b' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "int\b\|long\b\|double\b\|boolean\b" java/src/main/java/com/github/copilot/rpc/*.java | grep "private " | grep -oP 'private\s+(int|long|double|boolean)\b' | sort | uniq -c | sort -rn + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "Consumer<\|Function<\|Supplier<\|Executor\b" java/src/main/java/com/github/copilot/rpc/*.java | grep "private\|public" | grep -oP '(Consumer<[^>]+>|Function<[^>]+>|Supplier<[^>]+>|Executor)\b' | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rh "import com.github.copilot" java/src/main/java/com/github/copilot/rpc/*.java | grep -v "^import com.github.copilot.rpc\.\|^import com.github.copilot.generated.rpc\." | sort -u + +Ran terminal command: cd /home/edburns/workareas/copilot-sdk && grep -rn "SessionMetadataContextInfoResultContextInfo\|SessionMetadataSnapshotResultWorkspace\|SessionWorkspacesGetWorkspaceResultWorkspace\|SessionWorkspacesSaveLargePasteResultSaved\|SessionToolsUpdateSubagentSettingsParamsSubagents" java/src/generated/java/com/github/copilot/generated/rpc/*.java | head -10 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/McpServerConfig.java#1-1), lines 1 to 60 + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/Attachment.java) + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolResultObject.java#1-1), lines 1 to 60 + +Read [](file:///home/edburns/.vscode-server-insiders/data/User/workspaceStorage/0a47833b2fec2df1980b294571e4f51d/GitHub.copilot-chat/chat-session-resources/e01ff3e8-22ab-4df6-878d-6311894eaf0a/toolu_vrtx_01UxE8XUJogukPbL4Wd7rWHr__vscode-1782158068103/content.txt) + +Here's the **maximum viable set** derived from every type actually used across `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`: + +| Java type | JSON Schema | SDK usage examples | +|-----------|-------------|-------------------| +| `String` | `{"type": "string"}` | Pervasive (IDs, names, paths, messages) | +| `int`, `Integer` | `{"type": "integer"}` | `ProviderConfig`, `ModelLimits`, `McpServerConfig` | +| `long`, `Long` | `{"type": "integer"}` | Timestamps in hook inputs, JSON-RPC IDs | +| `double`, `Double` | `{"type": "number"}` | `ModelBilling.multiplier`, `InfiniteSessionConfig` thresholds | +| `float`, `Float` | `{"type": "number"}` | Not used in SDK today, but natural complement | +| `boolean`, `Boolean` | `{"type": "boolean"}` | Extensively in `SessionConfig`, hook outputs, options | +| `String[]` | `{"type": "array", "items": {"type": "string"}}` | `CopilotClientOptions.cliArgs` | +| `enum` types | `{"type": "string", "enum": ["V1", ...]}` | 92 enums (`ToolDefer`, `AgentMode`, 85 generated) | +| `UUID` | `{"type": "string", "format": "uuid"}` | Generated record ID fields | +| `OffsetDateTime` | `{"type": "string", "format": "date-time"}` | Generated record timestamp fields | +| `JsonNode` | `{}` (any) | `ToolInvocation.argumentsNode`, hook args/results | +| `Object` | `{}` (any) | `ToolDefinition.parameters`, `JsonRpcRequest.params` | +| `List`, `Collection` | `{"type": "array", "items": }` | 50+ distinct `T` across the API | +| `Map` | `{"type": "object", "additionalProperties": {"type": "string"}}` | `ProviderConfig.headers`, env maps | +| `Map` | `{"type": "object", "additionalProperties": {"type": "boolean"}}` | Generated records | +| `Map` | `{"type": "object", "additionalProperties": {"type": "integer"}}` | Generated records | +| `Map` | `{"type": "object"}` (opaque) | `ToolInvocation.getArguments()`, telemetry, extensionData | +| `Map` (typed `T`) | `{"type": "object", "additionalProperties": }` | `Map`, `Map`, etc. | +| `Map>` | `{"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}` | Generated records | +| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | ~690 types (26 handwritten records, ~65 POJOs, ~600 generated) | +| Sealed / `@JsonSubTypes` | `{"oneOf": [...]}` with discriminator | `McpServerConfig`, `MessageAttachment`, `SlashCommandInvocationResult` | +| `Optional` | Schema of `T`, not in `required` array | `Optional` in `SessionConfig` | +| `OptionalInt` | `{"type": "integer"}`, not in `required` | `CopilotClientOptions.getSessionIdleTimeoutSeconds()` | +| `OptionalDouble` | `{"type": "number"}`, not in `required` | `ModelBilling.getMultiplierOpt()` | + +**Types intentionally excluded** (not sensible as tool parameter types): + +| Java type | Reason | +|-----------|--------| +| `CompletableFuture` | Return type only, not a parameter type | +| `Consumer`, `Supplier`, `Function`, `Executor` | Callback/functional types, not serializable | +| `ObjectMapper`, `TypeReference` | Infrastructure, not data | + +The minimum viable set from the plan covers 8 rows. This maximum viable set covers **23 rows** — adding `String[]`, `UUID`, `OffsetDateTime`, `JsonNode`/`Object` (any), typed `Map` variants, sealed/polymorphic types, and `Optional` variants. These all have established Jackson serialization semantics in the existing SDK. + +Created 2 todos + +### 16:11 Prompt + +Now we come to "3.5 — Generated code shape". + +> **Question:** What exactly does the processor generate? + +This is why, before we started even creating the phase plan, we created a test of record that shows the current, non-ergonomic, way works and is functional. + +In the context of question 3.5, look at known-passing test `java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java`. Note that it defines these tools + +- `setPhaseTool` +- `searchTool` +- `grepOverrideTool` + +Compare that known-passing code with what you grabbed from ADR-005: + +> ```java +> // GENERATED — do not edit +> final class MyTools$$CopilotToolMeta { +> static List definitions(MyTools instance) { +> return List.of( +> new ToolDefinition("set_current_phase", "Sets the current phase", +> Map.of("type", "object", +> "properties", Map.of("phase", Map.of("type", "string", +> "description", "The phase to transition to")), +> "required", List.of("phase")), +> invocation -> { +> String phase = (String) invocation.getArguments().get("phase"); +> return CompletableFuture.completedFuture( +> instance.setCurrentPhase(phase)); +> }, null, null, null) +> ); +> } +> } +> ``` + +And then ask yourself the questions from the plan + +> 1. **Method invocation in generated code:** The generated lambda calls `instance.setCurrentPhase(phase)` directly — no reflection at runtime. But this requires the method to be accessible (not private). What access levels do we support? langchain4j uses `method.setAccessible(true)` at runtime. We'd need to either: +> - Require `public` or package-private methods, OR +> - Generate a helper that uses `MethodHandles.Lookup` to access private methods (complex), OR +> - Just require non-private. + +> 2. **Return type handling:** What does the generated code do with the method's return value? + +> 3. **Argument deserialization in generated code:** How does the generated lambda extract and coerce arguments? + +Does the known-passing test code even ask these questions? Does it answer them? How? + +### 16:18 Response + +Updated todo list + +Read [](file:///home/edburns/workareas/copilot-sdk/java/src/test/java/com/github/copilot/LowLevelToolDefinitionIT.java) + +The known-passing test **doesn't ask** these questions — it **bypasses** them entirely. Here's why: + +--- + +**Q1 (Access levels):** Not applicable. The test uses inline lambdas, not method references: + +```java +invocation -> { + currentPhase = args.phase(); + return CompletableFuture.completedFuture("Phase set to " + currentPhase); +} +``` + +There's no method to invoke — the tool logic IS the lambda. The access-level question only arises when a processor needs to generate code that calls `instance.someMethod()`. + +--- + +**Q2 (Return type handling):** Already resolved by fiat. Every handler explicitly returns `CompletableFuture`: + +```java +return CompletableFuture.completedFuture("Phase set to " + currentPhase); +return CompletableFuture.completedFuture("Found: item_alpha, item_beta"); +return CompletableFuture.completedFuture("CUSTOM_GREP: " + query); +``` + +The low-level API's handler type signature forces the user to produce `CompletableFuture` (or `Object`). No wrapping, no serialization — that's the user's problem. + +--- + +**Q3 (Argument deserialization):** Answered — it shows **both** patterns that the generated code would need: + +| Pattern | Test usage | +|---------|-----------| +| Jackson deserialization to a record | `invocation.getArgumentsAs(PhaseArgs.class)` for `setPhaseTool` | +| Direct map access + cast | `(String) invocation.getArguments().get("keyword")` for `searchTool` and `grepOverrideTool` | + +--- + +**Summary:** The low-level test is the **specification** of what the generated code must emit — it shows the exact lambdas the processor must synthesize. The test proves the wire format works. The three questions from 3.5 are about automating what the test author does manually: + +1. The processor must generate `instance.method()` calls → needs at least package-private access +2. The processor must wrap arbitrary return types into `CompletableFuture` → needs the return-type table +3. The processor must generate either `getArgumentsAs(Record.class)` or `getArguments().get("name")` + cast → depending on parameter complexity + +The test doesn't need to answer these questions because the human already made all three decisions by hand-writing the lambdas. + +### 16:19 Prompt + +I have been going through the plan `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` and filling in **Resolution** for each question. Absorb what we learned and output what I should record as the **Resolution** for question 3.5. + +### 16:21 Response + +Updated todo list + +**Resolution:** + +The generated `$$CopilotToolMeta` class produces lambdas identical to what `LowLevelToolDefinitionIT` writes by hand. Decisions: + +1. **Access levels:** Require at least package-private. The generated `$$CopilotToolMeta` lives in the same package as the user's class, so package-private and above work without reflection. Emit a compile error for `private` methods annotated with `@CopilotTool`. + +2. **Return type handling:** + + | Return type | Generated code | + |-------------|----------------| + | `String` | `CompletableFuture.completedFuture(instance.method(...))` | + | `CompletableFuture` | `instance.method(...)` (use as-is) | + | `void` | `instance.method(...); return CompletableFuture.completedFuture("Success")` | + | `CompletableFuture` (non-String) | `instance.method(...).thenApply(objectMapper::writeValueAsString)` | + | Other `T` | `CompletableFuture.completedFuture(objectMapper.writeValueAsString(instance.method(...)))` | + +3. **Argument deserialization:** Follow the two patterns proven in `LowLevelToolDefinitionIT`: + - **Simple types** (`String`, primitives, boxed): generate direct cast from `invocation.getArguments().get("name")` (e.g., `(String) args.get("city")`, `((Number) args.get("count")).intValue()`). + - **Complex types** (enums, records, POJOs): generate `objectMapper.convertValue(invocation.getArguments().get("name"), TargetType.class)`. + - **Single-record-parameter shortcut:** When a method has exactly one parameter that is a record/POJO (matching the full argument set), generate `invocation.getArgumentsAs(RecordType.class)` — the same pattern `LowLevelToolDefinitionIT.setPhaseTool` uses. + + The `ObjectMapper` instance: use a `private static final` field in the generated `$$CopilotToolMeta` class, initialized from `com.github.copilot.rpc.RpcMapper.INSTANCE` (already exists in the SDK). diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md index 7f48ccef3..a993cd30c 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md @@ -136,6 +136,47 @@ Minimum viable set (from langchain4j's `JsonSchemaElementUtils`): **Recommendation:** Start with the flat types (primitives, String, enums) and `List`. Defer nested records and polymorphic types to a follow-up. +**Resolution:** + +Here's the **maximum viable set** derived from every type actually used across `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`: + +| Java type | JSON Schema | SDK usage examples | +|-----------|-------------|-------------------| +| `String` | `{"type": "string"}` | Pervasive (IDs, names, paths, messages) | +| `int`, `Integer` | `{"type": "integer"}` | `ProviderConfig`, `ModelLimits`, `McpServerConfig` | +| `long`, `Long` | `{"type": "integer"}` | Timestamps in hook inputs, JSON-RPC IDs | +| `double`, `Double` | `{"type": "number"}` | `ModelBilling.multiplier`, `InfiniteSessionConfig` thresholds | +| `float`, `Float` | `{"type": "number"}` | Not used in SDK today, but natural complement | +| `boolean`, `Boolean` | `{"type": "boolean"}` | Extensively in `SessionConfig`, hook outputs, options | +| `String[]` | `{"type": "array", "items": {"type": "string"}}` | `CopilotClientOptions.cliArgs` | +| `enum` types | `{"type": "string", "enum": ["V1", ...]}` | 92 enums (`ToolDefer`, `AgentMode`, 85 generated) | +| `UUID` | `{"type": "string", "format": "uuid"}` | Generated record ID fields | +| `OffsetDateTime` | `{"type": "string", "format": "date-time"}` | Generated record timestamp fields | +| `JsonNode` | `{}` (any) | `ToolInvocation.argumentsNode`, hook args/results | +| `Object` | `{}` (any) | `ToolDefinition.parameters`, `JsonRpcRequest.params` | +| `List`, `Collection` | `{"type": "array", "items": }` | 50+ distinct `T` across the API | +| `Map` | `{"type": "object", "additionalProperties": {"type": "string"}}` | `ProviderConfig.headers`, env maps | +| `Map` | `{"type": "object", "additionalProperties": {"type": "boolean"}}` | Generated records | +| `Map` | `{"type": "object", "additionalProperties": {"type": "integer"}}` | Generated records | +| `Map` | `{"type": "object"}` (opaque) | `ToolInvocation.getArguments()`, telemetry, extensionData | +| `Map` (typed `T`) | `{"type": "object", "additionalProperties": }` | `Map`, `Map`, etc. | +| `Map>` | `{"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}` | Generated records | +| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | ~690 types (26 handwritten records, ~65 POJOs, ~600 generated) | +| Sealed / `@JsonSubTypes` | `{"oneOf": [...]}` with discriminator | `McpServerConfig`, `MessageAttachment`, `SlashCommandInvocationResult` | +| `Optional` | Schema of `T`, not in `required` array | `Optional` in `SessionConfig` | +| `OptionalInt` | `{"type": "integer"}`, not in `required` | `CopilotClientOptions.getSessionIdleTimeoutSeconds()` | +| `OptionalDouble` | `{"type": "number"}`, not in `required` | `ModelBilling.getMultiplierOpt()` | + +**Types intentionally excluded** (not sensible as tool parameter types): + +| Java type | Reason | +|-----------|--------| +| `CompletableFuture` | Return type only, not a parameter type | +| `Consumer`, `Supplier`, `Function`, `Executor` | Callback/functional types, not serializable | +| `ObjectMapper`, `TypeReference` | Infrastructure, not data | + +The minimum viable set from the plan covers 8 rows. This maximum viable set covers **23 rows** — adding `String[]`, `UUID`, `OffsetDateTime`, `JsonNode`/`Object` (any), typed `Map` variants, sealed/polymorphic types, and `Optional` variants. These all have established Jackson serialization semantics in the existing SDK. + ### 3.5 — Generated code shape **Question:** What exactly does the processor generate? @@ -197,6 +238,29 @@ final class MyTools$$CopilotToolMeta { ``` **Recommendation:** Generate direct casts for primitives/String, and `ObjectMapper.convertValue()` for enums, records, and complex types. The `ObjectMapper` instance can come from a static field in the generated class. + +**Resolution:** + +The generated `$$CopilotToolMeta` class produces lambdas identical to what `LowLevelToolDefinitionIT` writes by hand. Decisions: + +1. **Access levels:** Require at least package-private. The generated `$$CopilotToolMeta` lives in the same package as the user's class, so package-private and above work without reflection. Emit a compile error for `private` methods annotated with `@CopilotTool`. + +2. **Return type handling:** + + | Return type | Generated code | + |-------------|----------------| + | `String` | `CompletableFuture.completedFuture(instance.method(...))` | + | `CompletableFuture` | `instance.method(...)` (use as-is) | + | `void` | `instance.method(...); return CompletableFuture.completedFuture("Success")` | + | `CompletableFuture` (non-String) | `instance.method(...).thenApply(objectMapper::writeValueAsString)` | + | Other `T` | `CompletableFuture.completedFuture(objectMapper.writeValueAsString(instance.method(...)))` | + +3. **Argument deserialization:** Follow the two patterns proven in `LowLevelToolDefinitionIT`: + - **Simple types** (`String`, primitives, boxed): generate direct cast from `invocation.getArguments().get("name")` (e.g., `(String) args.get("city")`, `((Number) args.get("count")).intValue()`). + - **Complex types** (enums, records, POJOs): generate `objectMapper.convertValue(invocation.getArguments().get("name"), TargetType.class)`. + - **Single-record-parameter shortcut:** When a method has exactly one parameter that is a record/POJO (matching the full argument set), generate `invocation.getArgumentsAs(RecordType.class)` — the same pattern `LowLevelToolDefinitionIT.setPhaseTool` uses. + + The `ObjectMapper` instance: use a `private static final` field in the generated `$$CopilotToolMeta` class, initialized from `com.github.copilot.rpc.RpcMapper.INSTANCE` (already exists in the SDK). ### 3.6 — `ToolDefinition.fromObject(Object)` registration API @@ -231,6 +295,8 @@ public static List fromObject(Object instance) { **Recommendation:** Implement the reflection fallback but mark it `@CopilotExperimental` separately. The primary path is the generated `$$CopilotToolMeta`. +**Resolution** we only want the processor approach. + ### 3.7 — `module-info.java` impact The SDK uses JPMS. The processor generates classes into the user's module, not the SDK's. But `fromObject()` uses `Class.forName()` which needs the generated class to be accessible. From cfe0874ae750e727f2e646e1cb53f1a30298ccb1 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 17:03:18 -0400 Subject: [PATCH 06/29] WIP: Phase 3. Question 3.6: Answer --- ...ance-reduction-for-implementation-plan.md# | 492 ++++++++++++++++++ .../20260622-prompts.md | 78 +++ .../dd3021192/dependency-reduced-pom.xml | 39 ++ .../dd3021192/pom.xml | 55 ++ .../main/java/com/github/dd3021192/Main.java | 58 +++ .../dd3021192/MyTools$$CopilotToolMeta.java | 40 ++ .../java/com/github/dd3021192/MyTools.java | 20 + .../dd3021192/src/main/java/module-info.java | 9 + 8 files changed, 791 insertions(+) create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/dependency-reduced-pom.xml create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/Main.java create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools$$CopilotToolMeta.java create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools.java create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/module-info.java diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# b/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# new file mode 100644 index 000000000..105ca7c57 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# @@ -0,0 +1,492 @@ +# Implementation plan: `@CopilotTool` ergonomics (issue #1682) + +Human DRI: Ed Burns +ADR: `java/docs/adr/adr-005-tool-definition.md` +Issue: https://github.com/github/copilot-sdk/issues/1682 + +--- + +## Completed phases + +### Phase 1 ✅ — Define the problem and architectural decision + +- ADR-005 evaluates three options (status quo, record-as-schema, annotation-on-method). +- Decision: annotation-on-method with compile-time JSR 269 processor (langchain4j-style API, Micronaut-style implementation). + +### Phase 2 ✅ — Verify the existing low-level path works in Java + +- `test/snapshots/tools/low_level_tool_definition.yaml` created. +- `LowLevelToolDefinitionIT` passes with explicit `ToolDefinition.create()` / `createOverride()`. +- This proves the low-level API is correct and will serve as the foundation that the high-level API delegates to. + +--- + +## Phase 3 — Ignorance reduction: questions to answer before writing code + +This phase is about eliminating unknowns. Each item is a question or spike. Resolve these **before** writing production code. + +### 3.1 — Package placement + +**Question:** Where do `@CopilotTool` and `@Param` live? + +Current SDK structure is a single module (`copilot-sdk-java`). Two options: + +| Option | Location | Trade-off | +|--------|----------|-----------| +| A | `com.github.copilot.rpc` (alongside `ToolDefinition`) | Keeps everything together but the `rpc` package is already dense (40+ classes). | +| B | New package `com.github.copilot.tool` | Cleaner separation; the `tool` package holds annotations, processor, and `ToolDefinition.fromObject()`. But `ToolDefinition` itself stays in `rpc` (it's a JSON-RPC type). | + +**Recommendation:** Option B — new `com.github.copilot.tool` package for annotations + processor + schema generation. `ToolDefinition` stays in `rpc` and gets a new static method `fromObject(Object)` that delegates to `tool` package internals. + +**Action:** Decide; update `module-info.java` exports if new package is added. + +**Resolution:** Select Option B. + +### 3.2 — `@CopilotTool` annotation design + +**Question:** What attributes does `@CopilotTool` need? + +Based on ADR-005 and the C#/langchain4j comparisons: + +```java +@Documented +@Retention(RetentionPolicy.SOURCE) // only needed at compile time for processor +@Target(ElementType.METHOD) +@CopilotExperimental +public @interface CopilotTool { + /** Tool description (sent to the model). */ + String value(); + + /** Tool name. Defaults to method name converted to snake_case. */ + String name() default ""; + + /** Whether this tool overrides a built-in tool. */ + boolean overridesBuiltInTool() default false; + + /** Whether to skip permission checks. */ + boolean skipPermission() default false; +} +``` + +**Open questions:** + +1. Should `@CopilotTool` have `@Retention(SOURCE)` (processor-only, like Dagger) or `RUNTIME` (fallback reflection path, like langchain4j)? ADR-005 says "compile-time preferred, runtime fallback" — if we want a fallback path, we need `RUNTIME`. If we commit to processor-only, `SOURCE` is sufficient. + +2. Is `ToolDefer` (the defer config from `ToolDefinition`) needed on the annotation, or is that too niche for v1? + +**Recommendation:** Start with `RUNTIME` retention so the reflection fallback works. Defer `ToolDefer` support to a follow-up. + +**Resolution:** Select `RUNTIME` and `ToolDefer` support. + +### 3.3 — `@Param` annotation design + +**Question:** What attributes does `@Param` need? + +```java +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Param { + /** Parameter description (sent to the model). */ + String value() default ""; + + /** Parameter name override. Defaults to the actual parameter name. */ + String name() default ""; + + /** Whether this parameter is required. Default true. */ + boolean required() default true; + + /** Optional default value when the argument is omitted. */ + String defaultValue() default ""; +} +``` + +**Resolution:** Support `defaultValue()` in v1 (langchain4j parity) and make it behaviorally effective, not docs-only. + +Implementation rules: +- Emit JSON Schema defaults at `properties..default` for model guidance. +- Apply defaults at invocation time when an argument key is missing, then do normal coercion/casting and method invocation. +- Forbid `required=true` together with a non-empty `defaultValue()` (compile-time error in processor, matching runtime reflection fallback validation). +- Parse and validate `defaultValue()` against the Java parameter type (fail fast on mismatch). +- Ensure compile-time generated path and runtime reflection fallback use identical defaulting semantics. + +### 3.4 — Type-to-JSON-Schema mapping + +**Question:** What Java types do we need to map to JSON Schema, and how? + +Minimum viable set (from langchain4j's `JsonSchemaElementUtils`): + +| Java type | JSON Schema | +|-----------|-------------| +| `String` | `{"type": "string"}` | +| `int`, `Integer`, `long`, `Long` | `{"type": "integer"}` | +| `double`, `Double`, `float`, `Float` | `{"type": "number"}` | +| `boolean`, `Boolean` | `{"type": "boolean"}` | +| `enum` types | `{"type": "string", "enum": ["V1", "V2", ...]}` | +| `List`, `Collection` | `{"type": "array", "items": }` | +| `Map` | `{"type": "object"}` (opaque — no inner schema) | +| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | + +**Key design decision:** The annotation processor must generate this schema at compile time from `javax.lang.model` types (`TypeMirror`, `DeclaredType`, etc.), NOT from `java.lang.reflect` types. This is different from langchain4j which does it at runtime. + +**Spike needed:** Write a small proof-of-concept that maps `TypeMirror` → JSON Schema `Map` literal in generated source code. The tricky cases are: +- Enum constants (processor can see them via `ElementKind.ENUM_CONSTANT`) +- Generic type arguments on `List` (processor sees them via `DeclaredType.getTypeArguments()`) +- Recursive/nested records (need `$defs` or just go one level deep for v1) + +**Recommendation:** Start with the flat types (primitives, String, enums) and `List`. Defer nested records and polymorphic types to a follow-up. + +**Resolution:** + +Here's the **maximum viable set** derived from every type actually used across `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`: + +| Java type | JSON Schema | SDK usage examples | +|-----------|-------------|-------------------| +| `String` | `{"type": "string"}` | Pervasive (IDs, names, paths, messages) | +| `int`, `Integer` | `{"type": "integer"}` | `ProviderConfig`, `ModelLimits`, `McpServerConfig` | +| `long`, `Long` | `{"type": "integer"}` | Timestamps in hook inputs, JSON-RPC IDs | +| `double`, `Double` | `{"type": "number"}` | `ModelBilling.multiplier`, `InfiniteSessionConfig` thresholds | +| `float`, `Float` | `{"type": "number"}` | Not used in SDK today, but natural complement | +| `boolean`, `Boolean` | `{"type": "boolean"}` | Extensively in `SessionConfig`, hook outputs, options | +| `String[]` | `{"type": "array", "items": {"type": "string"}}` | `CopilotClientOptions.cliArgs` | +| `enum` types | `{"type": "string", "enum": ["V1", ...]}` | 92 enums (`ToolDefer`, `AgentMode`, 85 generated) | +| `UUID` | `{"type": "string", "format": "uuid"}` | Generated record ID fields | +| `OffsetDateTime` | `{"type": "string", "format": "date-time"}` | Generated record timestamp fields | +| `JsonNode` | `{}` (any) | `ToolInvocation.argumentsNode`, hook args/results | +| `Object` | `{}` (any) | `ToolDefinition.parameters`, `JsonRpcRequest.params` | +| `List`, `Collection` | `{"type": "array", "items": }` | 50+ distinct `T` across the API | +| `Map` | `{"type": "object", "additionalProperties": {"type": "string"}}` | `ProviderConfig.headers`, env maps | +| `Map` | `{"type": "object", "additionalProperties": {"type": "boolean"}}` | Generated records | +| `Map` | `{"type": "object", "additionalProperties": {"type": "integer"}}` | Generated records | +| `Map` | `{"type": "object"}` (opaque) | `ToolInvocation.getArguments()`, telemetry, extensionData | +| `Map` (typed `T`) | `{"type": "object", "additionalProperties": }` | `Map`, `Map`, etc. | +| `Map>` | `{"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}` | Generated records | +| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | ~690 types (26 handwritten records, ~65 POJOs, ~600 generated) | +| Sealed / `@JsonSubTypes` | `{"oneOf": [...]}` with discriminator | `McpServerConfig`, `MessageAttachment`, `SlashCommandInvocationResult` | +| `Optional` | Schema of `T`, not in `required` array | `Optional` in `SessionConfig` | +| `OptionalInt` | `{"type": "integer"}`, not in `required` | `CopilotClientOptions.getSessionIdleTimeoutSeconds()` | +| `OptionalDouble` | `{"type": "number"}`, not in `required` | `ModelBilling.getMultiplierOpt()` | + +**Types intentionally excluded** (not sensible as tool parameter types): + +| Java type | Reason | +|-----------|--------| +| `CompletableFuture` | Return type only, not a parameter type | +| `Consumer`, `Supplier`, `Function`, `Executor` | Callback/functional types, not serializable | +| `ObjectMapper`, `TypeReference` | Infrastructure, not data | + +The minimum viable set from the plan covers 8 rows. This maximum viable set covers **23 rows** — adding `String[]`, `UUID`, `OffsetDateTime`, `JsonNode`/`Object` (any), typed `Map` variants, sealed/polymorphic types, and `Optional` variants. These all have established Jackson serialization semantics in the existing SDK. + +### 3.5 — Generated code shape + +**Question:** What exactly does the processor generate? + +ADR-005 proposes: + +```java +// GENERATED — do not edit +final class MyTools$$CopilotToolMeta { + static List definitions(MyTools instance) { + return List.of( + new ToolDefinition("set_current_phase", "Sets the current phase", + Map.of("type", "object", + "properties", Map.of("phase", Map.of("type", "string", + "description", "The phase to transition to")), + "required", List.of("phase")), + invocation -> { + String phase = (String) invocation.getArguments().get("phase"); + return CompletableFuture.completedFuture( + instance.setCurrentPhase(phase)); + }, null, null, null) + ); + } +} +``` + +**Open questions:** + +1. **Method invocation in generated code:** The generated lambda calls `instance.setCurrentPhase(phase)` directly — no reflection at runtime. But this requires the method to be accessible (not private). What access levels do we support? langchain4j uses `method.setAccessible(true)` at runtime. We'd need to either: + - Require `public` or package-private methods, OR + - Generate a helper that uses `MethodHandles.Lookup` to access private methods (complex), OR + - Just require non-private. + + **Recommendation:** Require at least package-private. The generated `$$CopilotToolMeta` class is in the same package, so package-private and above work. Emit a compile error for `private` methods annotated with `@CopilotTool`. + +2. **Return type handling:** What does the generated code do with the method's return value? + + | Return type | Generated behavior | + |-------------|-------------------| + | `String` | Wrap in `CompletableFuture.completedFuture(result)` | + | `CompletableFuture` | Use as-is (native async) | + | `CompletableFuture` | `.thenApply(objectMapper::writeValueAsString)` | + | `void` | `CompletableFuture.completedFuture("Success")` | + | Other `T` | JSON-serialize via Jackson `ObjectMapper` | + + **Recommendation:** Support `String`, `void`, `CompletableFuture`, and `CompletableFuture` for v1. Other return types get JSON-serialized (since Jackson is already a dependency). + +3. **Argument deserialization in generated code:** How does the generated lambda extract and coerce arguments? + + For simple types, the generated code can cast directly from the `Map` returned by `invocation.getArguments()`: + ```java + String city = (String) invocation.getArguments().get("city"); + int count = ((Number) invocation.getArguments().get("count")).intValue(); + ``` + + For complex types (records, enums), use `invocation.getArgumentsAs()` or Jackson's `ObjectMapper.convertValue()`: + ```java + Phase phase = objectMapper.convertValue(invocation.getArguments().get("phase"), Phase.class); + ``` + + **Recommendation:** Generate direct casts for primitives/String, and `ObjectMapper.convertValue()` for enums, records, and complex types. The `ObjectMapper` instance can come from a static field in the generated class. + +**Resolution:** + +The generated `$$CopilotToolMeta` class produces lambdas identical to what `LowLevelToolDefinitionIT` writes by hand. Decisions: + +1. **Access levels:** Require at least package-private. The generated `$$CopilotToolMeta` lives in the same package as the user's class, so package-private and above work without reflection. Emit a compile error for `private` methods annotated with `@CopilotTool`. + +2. **Return type handling:** + + | Return type | Generated code | + |-------------|----------------| + | `String` | `CompletableFuture.completedFuture(instance.method(...))` | + | `CompletableFuture` | `instance.method(...)` (use as-is) | + | `void` | `instance.method(...); return CompletableFuture.completedFuture("Success")` | + | `CompletableFuture` (non-String) | `instance.method(...).thenApply(objectMapper::writeValueAsString)` | + | Other `T` | `CompletableFuture.completedFuture(objectMapper.writeValueAsString(instance.method(...)))` | + +3. **Argument deserialization:** Follow the two patterns proven in `LowLevelToolDefinitionIT`: + - **Simple types** (`String`, primitives, boxed): generate direct cast from `invocation.getArguments().get("name")` (e.g., `(String) args.get("city")`, `((Number) args.get("count")).intValue()`). + - **Complex types** (enums, records, POJOs): generate `objectMapper.convertValue(invocation.getArguments().get("name"), TargetType.class)`. + - **Single-record-parameter shortcut:** When a method has exactly one parameter that is a record/POJO (matching the full argument set), generate `invocation.getArgumentsAs(RecordType.class)` — the same pattern `LowLevelToolDefinitionIT.setPhaseTool` uses. + + The `ObjectMapper` instance: use a `private static final` field in the generated `$$CopilotToolMeta` class, initialized from `com.github.copilot.rpc.RpcMapper.INSTANCE` (already exists in the SDK). + +### 3.6 — `ToolDefinition.fromObject(Object)` registration API + +**Question:** How does the user get from "an object with `@CopilotTool` methods" to a `List`? + +```java +// Primary API — loads generated $$CopilotToolMeta class +List tools = ToolDefinition.fromObject(myToolsInstance); + +// Variant: from class (for static tools) +List tools = ToolDefinition.fromClass(MyTools.class); +``` + +**Implementation:** + +```java +public static List fromObject(Object instance) { + Class clazz = instance.getClass(); + String metaClassName = clazz.getName() + "$$CopilotToolMeta"; + try { + Class metaClass = Class.forName(metaClassName); + Method defs = metaClass.getMethod("definitions", clazz); + return (List) defs.invoke(null, instance); + } catch (ClassNotFoundException e) { + // Fallback: runtime reflection (if we support it) + return fromObjectReflective(instance); + } +} +``` + +**Open question:** Do we want the reflection fallback? It's nice for users who don't run the processor (e.g., scripting, prototyping), but it adds code and the `-parameters` concern. + +**Recommendation:** Implement the reflection fallback but mark it `@CopilotExperimental` separately. The primary path is the generated `$$CopilotToolMeta`. + +**Resolution** we only want the processor approach. + +### 3.7 — `module-info.java` impact + +The SDK uses JPMS. The processor generates classes into the user's module, not the SDK's. But `fromObject()` uses `Class.forName()` which needs the generated class to be accessible. + +**Question:** Does the generated `$$CopilotToolMeta` class in the user's module need to be exported for `fromObject()` to find it? + +**Answer:** No. `Class.forName()` with the caller's classloader works within the same module. And in the typical unnamed-module (classpath) case, everything is accessible. If the user has a named module, the generated class is in the same package as their tools class, so it's accessible. + +**Action:** Verify this works in a simple named-module test. + + + +### 3.8 — Processor registration + +**Question:** How is the new `@CopilotTool` processor registered alongside `CopilotExperimentalProcessor`? + +The existing `META-INF/services/javax.annotation.processing.Processor` lists `CopilotExperimentalProcessor`. Add the new processor to the same file: + +``` +com.github.copilot.CopilotExperimentalProcessor +com.github.copilot.tool.CopilotToolProcessor +``` + +And in `module-info.java`: +```java +provides javax.annotation.processing.Processor + with CopilotExperimentalProcessor, CopilotToolProcessor; +``` + +**No issues expected here** — this is standard JSR 269 multi-processor registration. + +--- + +## Phase 4 — Implementation (the build order) + +After Phase 3 questions are resolved, implement in this order. Each step should be a separately testable commit. + +### 4.1 — Annotations (`@CopilotTool`, `@Param`) + +**What:** Create the two annotation classes. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/CopilotTool.java` +- `java/src/main/java/com/github/copilot/tool/Param.java` + +**Tests:** +- Compile-only: ensure they compile, can be applied to methods/parameters, and are annotated with `@CopilotExperimental`. +- No runtime behavior yet. + +**Gating criteria:** `mvn clean compile` passes. + +### 4.2 — Schema generation utility (compile-time) + +**What:** A utility class that, given `javax.lang.model` types, produces the `Map` JSON Schema as a Java source code literal. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/SchemaGenerator.java` (compile-time, works with `TypeMirror`) + +**Tests:** +- Unit tests that exercise the type-to-schema mapping with mock `TypeMirror` instances (or integration tests via the annotation processor in a test compilation). + +**Gating criteria:** Can generate correct schema `Map` source code for: `String`, `int`, `boolean`, `double`, `enum`, `List`, a simple record. + +### 4.3 — Annotation processor (`CopilotToolProcessor`) + +**What:** JSR 269 processor that finds `@CopilotTool` methods and generates `$$CopilotToolMeta` classes. + +**Files to create:** +- `java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java` + +**Files to modify:** +- `java/src/main/resources/META-INF/services/javax.annotation.processing.Processor` — add the new processor +- `java/src/main/java/module-info.java` — add `provides` clause and `exports com.github.copilot.tool` + +**Tests:** +- **Compilation tests:** Compile test source files with `@CopilotTool` methods and verify: + - `$$CopilotToolMeta` class is generated + - Generated schema matches expected JSON Schema + - Compile errors emitted for: private methods, unsupported parameter types, duplicate tool names +- Use `javax.tools.JavaCompiler` programmatically (same pattern langchain4j uses for testing annotation processors). + +**Gating criteria:** Processor generates correct `$$CopilotToolMeta` for a class with 2-3 `@CopilotTool` methods. + +### 4.4 — `ToolDefinition.fromObject(Object)` + +**What:** The runtime bridge that loads generated metadata and returns `List`. + +**Files to modify:** +- `java/src/main/java/com/github/copilot/rpc/ToolDefinition.java` — add `fromObject(Object)` and `fromClass(Class)` static methods + +**Tests:** +- Unit test: create a test class with `@CopilotTool` methods, compile it (processor generates metadata), call `fromObject()`, verify the returned `List` has correct names, descriptions, schemas, and working handlers. + +**Gating criteria:** `ToolDefinition.fromObject(new MyTestTools())` returns a list with working tool definitions. + +### 4.5 — E2E integration test + +**What:** An E2E failsafe IT that uses `@CopilotTool` + `ToolDefinition.fromObject()` against the replay proxy. + +**Files to create:** +- `test/snapshots/tools/ergonomic_tool_definition.yaml` — new snapshot (may be identical to `low_level_tool_definition.yaml` since the wire format is the same) +- `java/src/test/java/com/github/copilot/ErgonomicToolDefinitionIT.java` + +**The test will look like:** + +```java +class MyTestTools { + String currentPhase; + + @CopilotTool("Sets the current phase of the agent") + String setCurrentPhase(@Param("The phase to transition to") String phase) { + currentPhase = phase; + return "Phase set to " + phase; + } + + @CopilotTool("Search for items by keyword") + String searchItems(@Param("Search keyword") String keyword) { + return "Found: item_alpha, item_beta"; + } + + @CopilotTool(value = "Custom grep override", name = "grep", overridesBuiltInTool = true) + String grepOverride(@Param("Search query") String query) { + return "CUSTOM_GREP: " + query; + } +} + +@Test +void ergonomicToolDefinition() throws Exception { + MyTestTools tools = new MyTestTools(); + List toolDefs = ToolDefinition.fromObject(tools); + + // ... create session with toolDefs, send prompt, assert same behavior + // as LowLevelToolDefinitionIT +} +``` + +**Gating criteria:** Test passes with the same assertions as `LowLevelToolDefinitionIT` — proving the ergonomic API produces identical behavior to the explicit API. + +### 4.6 — Reflection fallback (optional, can defer) + +**What:** `fromObject()` falls back to runtime reflection when `$$CopilotToolMeta` is not found. + +**Files to create/modify:** +- `java/src/main/java/com/github/copilot/tool/ReflectiveToolScanner.java` +- Modify `ToolDefinition.fromObject()` to call this on `ClassNotFoundException` + +**Tests:** +- Compile a test class WITHOUT the annotation processor, call `fromObject()`, verify it still works (with `-parameters` flag). + +**Gating criteria:** Fallback path produces the same `List` as the processor-generated path. + +--- + +## Phase 5 — Documentation and examples + +- Update `java/README.md` with the ergonomic tool definition example. +- Add a "Tools" section showing both the low-level and high-level APIs. +- Reference ADR-005 for design rationale. + +--- + +## Phase 6 — Port to `add-tests-that-use-ergonomic_tool_definition.yaml.md` + +Same cycle as Phase 2 → `add-tests-that-use-low_level_tool_definition.yaml.md`: once the Java E2E test passes with the ergonomic API, create a prompt to port the test to dotnet/go/nodejs/python/rust. + +**Note:** This may not be applicable — the ergonomic API (`@CopilotTool`) is Java-specific. The other SDKs already have their own ergonomic paths. The snapshot can be shared, but the test code is language-specific by nature. Evaluate whether this phase is needed after Phase 5. + +--- + +## Reference: how langchain4j and Micronaut do it + +### langchain4j (runtime reflection) + +- `@Tool` on methods, `@P` on parameters. +- `ToolSpecifications.toolSpecificationsFrom(Object)` scans methods at runtime. +- `JsonSchemaElementUtils` maps `java.lang.reflect.Type` → JSON Schema. +- `DefaultToolExecutor.executeWithContext()` invokes via `Method.invoke()` with argument coercion. +- Requires `-parameters` javac flag or explicit `@P(name="...")`. +- Source: `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/` + +### Micronaut (compile-time annotation processor) + +- `AbstractInjectAnnotationProcessor` (extends `AbstractProcessor`) is the JSR 269 entry point. +- `TypeElementVisitor` SPI pattern: visitors registered via SPI walk the AST. +- `BeanDefinitionWriter` generates bytecode companion classes (`$Definition`, `$Definition$Exec`). +- `ParameterElement.getName()` at compile time — no `-parameters` flag needed. +- Source: `inject-java/src/main/java/io/micronaut/annotation/processing/` + +### Our approach: langchain4j's API + Micronaut's implementation strategy + +- **User-facing API** matches langchain4j: `@CopilotTool` on methods, `@Param` on parameters, `fromObject()` to discover. +- **Implementation** matches Micronaut: JSR 269 processor generates companion classes at compile time, no runtime reflection in the happy path, no `-parameters` requirement. +- **Fallback** path uses langchain4j-style runtime reflection for users who don't run the processor (prototyping, scripting). diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md index 294c7fd71..c28b66927 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -352,3 +352,81 @@ The generated `$$CopilotToolMeta` class produces lambdas identical to what `LowL - **Single-record-parameter shortcut:** When a method has exactly one parameter that is a record/POJO (matching the full argument set), generate `invocation.getArgumentsAs(RecordType.class)` — the same pattern `LowLevelToolDefinitionIT.setPhaseTool` uses. The `ObjectMapper` instance: use a `private static final` field in the generated `$$CopilotToolMeta` class, initialized from `com.github.copilot.rpc.RpcMapper.INSTANCE` (already exists in the SDK). + +### 16:37 Prompt + +Read `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` to get the context. + +In this session, we are working on "Phase 3 — Ignorance reduction: questions to answer before writing code". + +We have completed and answered up to, but not including, "3.7 — `module-info.java` impact". + +I will proceed after this prompt. + +### 16:40 Response + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#1-1), lines 1 to 300 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#300-300), lines 300 to 491 + +I've read the full document. Here's my understanding of where we stand: + +**Phase 3 — completed items (3.1–3.6):** +- **3.1** — Option B: new `com.github.copilot.tool` package for annotations + processor + schema generation; `ToolDefinition` stays in `rpc`. +- **3.2** — `@CopilotTool` with `RUNTIME` retention; include `ToolDefer` support. +- **3.3** — `@Param` with `defaultValue()` that is behaviorally effective; `required=true` + non-empty `defaultValue` is a compile-time error. +- **3.4** — Maximum viable type set (23 rows) including `Optional`, sealed/`@JsonSubTypes`, typed `Map` variants, `UUID`, `OffsetDateTime`, etc. +- **3.5** — Generated `$$CopilotToolMeta` class; package-private minimum access; direct casts for simple types, `ObjectMapper.convertValue()` for complex types; `RpcMapper.INSTANCE` for the `ObjectMapper`. +- **3.6** — Processor-only approach (no reflection fallback). + +**Remaining items:** +- **3.7** — `module-info.java` impact (answer is drafted: no special exports needed since generated class is in user's package, but needs verification) +- **3.8** — Processor registration (add to `META-INF/services` + `module-info.java` `provides` clause) + +Ready for your next instruction. + +## dd-3021192-module-info.java-impact + +### 16:40 Prompt + +Create a self-contained Maven module rooted at `1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml`. The `artifactId` is `dd3021192` and the `groupId` is `com.github`. It depends on + +```xml + com.github + copilot-sdk-java + 1.0.2-SNAPSHOT +``` + +It creates a JAR that can be run with java -jar . This is the "simple named-module test." mentioned in "3.7 — `module-info.java` impact". + +When you run the jar, it demonstrates that this works. + +### 17:02 Response + +Updated todo list + +Searched for text `module com.github.copilot` (`**/java/src/main/java/module-info.java`), 1 result + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/module-info.java) + +Now I have the module name. Let me create the test module. The key idea: simulate what the processor will generate (`$$CopilotToolMeta`) and prove that `Class.forName()` can find it from within a named JPMS module. + +Created [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml) + +Created [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/module-info.java) + +Created [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools.java) + +Created 5 todos + +Created [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools%24%24CopilotToolMeta.java) + +Created [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/Main.java) + +Now let me build and run it. First, I need to ensure `copilot-sdk-java` is installed in the local Maven repo, then build the test module. + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && cd /Users/edburns/workareas/copilot-sdk/java && LOG="$(date +%Y%m%d-%H%M)-job-logs.txt" && mvn install -DskipTests -pl . > "$LOG" 2>&1 & tail -f "$LOG" + +Made changes. diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/dependency-reduced-pom.xml b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/dependency-reduced-pom.xml new file mode 100644 index 000000000..75214f174 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/dependency-reduced-pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + com.github + dd3021192 + 1.0.0-SNAPSHOT + + + + maven-jar-plugin + 3.3.0 + + + + com.github.dd3021192.Main + + + + + + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + + + + UTF-8 + 17 + 17 + + diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml new file mode 100644 index 000000000..3d2b26a0e --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + com.github + dd3021192 + 1.0.0-SNAPSHOT + jar + + + 17 + 17 + UTF-8 + + + + + com.github + copilot-sdk-java + 1.0.2-SNAPSHOT + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.github.dd3021192.Main + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + + + diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/Main.java b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/Main.java new file mode 100644 index 000000000..a26845d10 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/Main.java @@ -0,0 +1,58 @@ +package com.github.dd3021192; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +/** + * Named-module JPMS test for issue #1682, Phase 3.7. + * + * Proves that ToolDefinition.fromObject() pattern works in a named module: + * - Class.forName() can locate the generated $$CopilotToolMeta companion class + * - The companion class is accessible (same package, same module) + * - Method invocation on the companion works without extra JPMS exports + */ +public class Main { + + public static void main(String[] args) throws Exception { + System.out.println("=== JPMS Named-Module Test for §3.7 ==="); + System.out.println("Module: " + Main.class.getModule().getName()); + System.out.println(); + + MyTools instance = new MyTools(); + Class toolsClass = instance.getClass(); + + // This is exactly what ToolDefinition.fromObject() will do: + String metaClassName = toolsClass.getName() + "$$CopilotToolMeta"; + System.out.println("Looking up generated meta class: " + metaClassName); + + // Step 1: Class.forName() — the critical JPMS question + Class metaClass = Class.forName(metaClassName); + System.out.println("[PASS] Class.forName() found: " + metaClass.getName()); + + // Step 2: Get the 'definitions' method + Method defsMethod = metaClass.getMethod("definitions", toolsClass); + System.out.println("[PASS] Found method: " + defsMethod); + + // Step 3: Invoke it + @SuppressWarnings("unchecked") + List> definitions = + (List>) defsMethod.invoke(null, instance); + System.out.println("[PASS] Invoked definitions(), got " + definitions.size() + " tool(s)"); + + // Step 4: Verify content + Map tool = definitions.get(0); + assert "set_current_phase".equals(tool.get("name")) + : "Expected tool name 'set_current_phase', got: " + tool.get("name"); + assert "Sets the current phase".equals(tool.get("description")) + : "Expected description mismatch"; + System.out.println("[PASS] Tool definition correct: " + tool.get("name") + + " — \"" + tool.get("description") + "\""); + + System.out.println(); + System.out.println("=== ALL CHECKS PASSED ==="); + System.out.println("Conclusion: Class.forName() works within a named JPMS module"); + System.out.println("for locating $$CopilotToolMeta in the same package. No extra"); + System.out.println("exports or opens directives are needed."); + } +} diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools$$CopilotToolMeta.java b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools$$CopilotToolMeta.java new file mode 100644 index 000000000..3c39f42b9 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools$$CopilotToolMeta.java @@ -0,0 +1,40 @@ +package com.github.dd3021192; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +/** + * Simulates the generated companion class that the annotation processor would produce. + * In real usage, this class is auto-generated at compile time. + * + * The key point: it lives in the SAME package as MyTools, so it can call + * package-private methods and is discoverable via Class.forName() from the same module. + */ +public final class MyTools$$CopilotToolMeta { + + private MyTools$$CopilotToolMeta() {} + + /** + * Returns tool definitions for the given MyTools instance. + * This mirrors what the real generated code will produce. + */ + public static List> definitions(MyTools instance) { + return List.of( + Map.of( + "name", "set_current_phase", + "description", "Sets the current phase", + "parameters", Map.of( + "type", "object", + "properties", Map.of( + "phase", Map.of( + "type", "string", + "description", "The phase to transition to" + ) + ), + "required", List.of("phase") + ) + ) + ); + } +} diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools.java b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools.java new file mode 100644 index 000000000..7105a5a8f --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/com/github/dd3021192/MyTools.java @@ -0,0 +1,20 @@ +package com.github.dd3021192; + +/** + * Simulates a user's tool class annotated with @CopilotTool methods. + * In real usage, the annotation processor would generate MyTools$$CopilotToolMeta. + */ +public class MyTools { + + private String currentPhase = "init"; + + // This would be annotated with @CopilotTool("Sets the current phase") + public String setCurrentPhase(String phase) { + this.currentPhase = phase; + return "Phase set to " + phase; + } + + public String getCurrentPhase() { + return currentPhase; + } +} diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/module-info.java b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/module-info.java new file mode 100644 index 000000000..1b1d803c7 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/src/main/java/module-info.java @@ -0,0 +1,9 @@ +/** + * Named-module test for issue #1682 Phase 3.7. + * + * Demonstrates that Class.forName() can locate a generated $$CopilotToolMeta + * companion class from within a named JPMS module, without requiring extra exports. + */ +module com.github.dd3021192 { + requires com.github.copilot.java; +} From e8408fa5c72236325c9a31d66ecfb10ca9813c23 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 17:38:59 -0400 Subject: [PATCH 07/29] Answer 3.7 --- ...ance-reduction-for-implementation-plan.md# | 492 ------------------ .../20260622-prompts.md | 1 + ...rance-reduction-for-implementation-plan.md | 12 + 3 files changed, 13 insertions(+), 492 deletions(-) delete mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# b/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# deleted file mode 100644 index 105ca7c57..000000000 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/#dd-3018003-ignorance-reduction-for-implementation-plan.md# +++ /dev/null @@ -1,492 +0,0 @@ -# Implementation plan: `@CopilotTool` ergonomics (issue #1682) - -Human DRI: Ed Burns -ADR: `java/docs/adr/adr-005-tool-definition.md` -Issue: https://github.com/github/copilot-sdk/issues/1682 - ---- - -## Completed phases - -### Phase 1 ✅ — Define the problem and architectural decision - -- ADR-005 evaluates three options (status quo, record-as-schema, annotation-on-method). -- Decision: annotation-on-method with compile-time JSR 269 processor (langchain4j-style API, Micronaut-style implementation). - -### Phase 2 ✅ — Verify the existing low-level path works in Java - -- `test/snapshots/tools/low_level_tool_definition.yaml` created. -- `LowLevelToolDefinitionIT` passes with explicit `ToolDefinition.create()` / `createOverride()`. -- This proves the low-level API is correct and will serve as the foundation that the high-level API delegates to. - ---- - -## Phase 3 — Ignorance reduction: questions to answer before writing code - -This phase is about eliminating unknowns. Each item is a question or spike. Resolve these **before** writing production code. - -### 3.1 — Package placement - -**Question:** Where do `@CopilotTool` and `@Param` live? - -Current SDK structure is a single module (`copilot-sdk-java`). Two options: - -| Option | Location | Trade-off | -|--------|----------|-----------| -| A | `com.github.copilot.rpc` (alongside `ToolDefinition`) | Keeps everything together but the `rpc` package is already dense (40+ classes). | -| B | New package `com.github.copilot.tool` | Cleaner separation; the `tool` package holds annotations, processor, and `ToolDefinition.fromObject()`. But `ToolDefinition` itself stays in `rpc` (it's a JSON-RPC type). | - -**Recommendation:** Option B — new `com.github.copilot.tool` package for annotations + processor + schema generation. `ToolDefinition` stays in `rpc` and gets a new static method `fromObject(Object)` that delegates to `tool` package internals. - -**Action:** Decide; update `module-info.java` exports if new package is added. - -**Resolution:** Select Option B. - -### 3.2 — `@CopilotTool` annotation design - -**Question:** What attributes does `@CopilotTool` need? - -Based on ADR-005 and the C#/langchain4j comparisons: - -```java -@Documented -@Retention(RetentionPolicy.SOURCE) // only needed at compile time for processor -@Target(ElementType.METHOD) -@CopilotExperimental -public @interface CopilotTool { - /** Tool description (sent to the model). */ - String value(); - - /** Tool name. Defaults to method name converted to snake_case. */ - String name() default ""; - - /** Whether this tool overrides a built-in tool. */ - boolean overridesBuiltInTool() default false; - - /** Whether to skip permission checks. */ - boolean skipPermission() default false; -} -``` - -**Open questions:** - -1. Should `@CopilotTool` have `@Retention(SOURCE)` (processor-only, like Dagger) or `RUNTIME` (fallback reflection path, like langchain4j)? ADR-005 says "compile-time preferred, runtime fallback" — if we want a fallback path, we need `RUNTIME`. If we commit to processor-only, `SOURCE` is sufficient. - -2. Is `ToolDefer` (the defer config from `ToolDefinition`) needed on the annotation, or is that too niche for v1? - -**Recommendation:** Start with `RUNTIME` retention so the reflection fallback works. Defer `ToolDefer` support to a follow-up. - -**Resolution:** Select `RUNTIME` and `ToolDefer` support. - -### 3.3 — `@Param` annotation design - -**Question:** What attributes does `@Param` need? - -```java -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.PARAMETER) -public @interface Param { - /** Parameter description (sent to the model). */ - String value() default ""; - - /** Parameter name override. Defaults to the actual parameter name. */ - String name() default ""; - - /** Whether this parameter is required. Default true. */ - boolean required() default true; - - /** Optional default value when the argument is omitted. */ - String defaultValue() default ""; -} -``` - -**Resolution:** Support `defaultValue()` in v1 (langchain4j parity) and make it behaviorally effective, not docs-only. - -Implementation rules: -- Emit JSON Schema defaults at `properties..default` for model guidance. -- Apply defaults at invocation time when an argument key is missing, then do normal coercion/casting and method invocation. -- Forbid `required=true` together with a non-empty `defaultValue()` (compile-time error in processor, matching runtime reflection fallback validation). -- Parse and validate `defaultValue()` against the Java parameter type (fail fast on mismatch). -- Ensure compile-time generated path and runtime reflection fallback use identical defaulting semantics. - -### 3.4 — Type-to-JSON-Schema mapping - -**Question:** What Java types do we need to map to JSON Schema, and how? - -Minimum viable set (from langchain4j's `JsonSchemaElementUtils`): - -| Java type | JSON Schema | -|-----------|-------------| -| `String` | `{"type": "string"}` | -| `int`, `Integer`, `long`, `Long` | `{"type": "integer"}` | -| `double`, `Double`, `float`, `Float` | `{"type": "number"}` | -| `boolean`, `Boolean` | `{"type": "boolean"}` | -| `enum` types | `{"type": "string", "enum": ["V1", "V2", ...]}` | -| `List`, `Collection` | `{"type": "array", "items": }` | -| `Map` | `{"type": "object"}` (opaque — no inner schema) | -| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | - -**Key design decision:** The annotation processor must generate this schema at compile time from `javax.lang.model` types (`TypeMirror`, `DeclaredType`, etc.), NOT from `java.lang.reflect` types. This is different from langchain4j which does it at runtime. - -**Spike needed:** Write a small proof-of-concept that maps `TypeMirror` → JSON Schema `Map` literal in generated source code. The tricky cases are: -- Enum constants (processor can see them via `ElementKind.ENUM_CONSTANT`) -- Generic type arguments on `List` (processor sees them via `DeclaredType.getTypeArguments()`) -- Recursive/nested records (need `$defs` or just go one level deep for v1) - -**Recommendation:** Start with the flat types (primitives, String, enums) and `List`. Defer nested records and polymorphic types to a follow-up. - -**Resolution:** - -Here's the **maximum viable set** derived from every type actually used across `com.github.copilot.rpc` and `com.github.copilot.generated.rpc`: - -| Java type | JSON Schema | SDK usage examples | -|-----------|-------------|-------------------| -| `String` | `{"type": "string"}` | Pervasive (IDs, names, paths, messages) | -| `int`, `Integer` | `{"type": "integer"}` | `ProviderConfig`, `ModelLimits`, `McpServerConfig` | -| `long`, `Long` | `{"type": "integer"}` | Timestamps in hook inputs, JSON-RPC IDs | -| `double`, `Double` | `{"type": "number"}` | `ModelBilling.multiplier`, `InfiniteSessionConfig` thresholds | -| `float`, `Float` | `{"type": "number"}` | Not used in SDK today, but natural complement | -| `boolean`, `Boolean` | `{"type": "boolean"}` | Extensively in `SessionConfig`, hook outputs, options | -| `String[]` | `{"type": "array", "items": {"type": "string"}}` | `CopilotClientOptions.cliArgs` | -| `enum` types | `{"type": "string", "enum": ["V1", ...]}` | 92 enums (`ToolDefer`, `AgentMode`, 85 generated) | -| `UUID` | `{"type": "string", "format": "uuid"}` | Generated record ID fields | -| `OffsetDateTime` | `{"type": "string", "format": "date-time"}` | Generated record timestamp fields | -| `JsonNode` | `{}` (any) | `ToolInvocation.argumentsNode`, hook args/results | -| `Object` | `{}` (any) | `ToolDefinition.parameters`, `JsonRpcRequest.params` | -| `List`, `Collection` | `{"type": "array", "items": }` | 50+ distinct `T` across the API | -| `Map` | `{"type": "object", "additionalProperties": {"type": "string"}}` | `ProviderConfig.headers`, env maps | -| `Map` | `{"type": "object", "additionalProperties": {"type": "boolean"}}` | Generated records | -| `Map` | `{"type": "object", "additionalProperties": {"type": "integer"}}` | Generated records | -| `Map` | `{"type": "object"}` (opaque) | `ToolInvocation.getArguments()`, telemetry, extensionData | -| `Map` (typed `T`) | `{"type": "object", "additionalProperties": }` | `Map`, `Map`, etc. | -| `Map>` | `{"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}` | Generated records | -| Records / POJOs | `{"type": "object", "properties": {...}, "required": [...]}` | ~690 types (26 handwritten records, ~65 POJOs, ~600 generated) | -| Sealed / `@JsonSubTypes` | `{"oneOf": [...]}` with discriminator | `McpServerConfig`, `MessageAttachment`, `SlashCommandInvocationResult` | -| `Optional` | Schema of `T`, not in `required` array | `Optional` in `SessionConfig` | -| `OptionalInt` | `{"type": "integer"}`, not in `required` | `CopilotClientOptions.getSessionIdleTimeoutSeconds()` | -| `OptionalDouble` | `{"type": "number"}`, not in `required` | `ModelBilling.getMultiplierOpt()` | - -**Types intentionally excluded** (not sensible as tool parameter types): - -| Java type | Reason | -|-----------|--------| -| `CompletableFuture` | Return type only, not a parameter type | -| `Consumer`, `Supplier`, `Function`, `Executor` | Callback/functional types, not serializable | -| `ObjectMapper`, `TypeReference` | Infrastructure, not data | - -The minimum viable set from the plan covers 8 rows. This maximum viable set covers **23 rows** — adding `String[]`, `UUID`, `OffsetDateTime`, `JsonNode`/`Object` (any), typed `Map` variants, sealed/polymorphic types, and `Optional` variants. These all have established Jackson serialization semantics in the existing SDK. - -### 3.5 — Generated code shape - -**Question:** What exactly does the processor generate? - -ADR-005 proposes: - -```java -// GENERATED — do not edit -final class MyTools$$CopilotToolMeta { - static List definitions(MyTools instance) { - return List.of( - new ToolDefinition("set_current_phase", "Sets the current phase", - Map.of("type", "object", - "properties", Map.of("phase", Map.of("type", "string", - "description", "The phase to transition to")), - "required", List.of("phase")), - invocation -> { - String phase = (String) invocation.getArguments().get("phase"); - return CompletableFuture.completedFuture( - instance.setCurrentPhase(phase)); - }, null, null, null) - ); - } -} -``` - -**Open questions:** - -1. **Method invocation in generated code:** The generated lambda calls `instance.setCurrentPhase(phase)` directly — no reflection at runtime. But this requires the method to be accessible (not private). What access levels do we support? langchain4j uses `method.setAccessible(true)` at runtime. We'd need to either: - - Require `public` or package-private methods, OR - - Generate a helper that uses `MethodHandles.Lookup` to access private methods (complex), OR - - Just require non-private. - - **Recommendation:** Require at least package-private. The generated `$$CopilotToolMeta` class is in the same package, so package-private and above work. Emit a compile error for `private` methods annotated with `@CopilotTool`. - -2. **Return type handling:** What does the generated code do with the method's return value? - - | Return type | Generated behavior | - |-------------|-------------------| - | `String` | Wrap in `CompletableFuture.completedFuture(result)` | - | `CompletableFuture` | Use as-is (native async) | - | `CompletableFuture` | `.thenApply(objectMapper::writeValueAsString)` | - | `void` | `CompletableFuture.completedFuture("Success")` | - | Other `T` | JSON-serialize via Jackson `ObjectMapper` | - - **Recommendation:** Support `String`, `void`, `CompletableFuture`, and `CompletableFuture` for v1. Other return types get JSON-serialized (since Jackson is already a dependency). - -3. **Argument deserialization in generated code:** How does the generated lambda extract and coerce arguments? - - For simple types, the generated code can cast directly from the `Map` returned by `invocation.getArguments()`: - ```java - String city = (String) invocation.getArguments().get("city"); - int count = ((Number) invocation.getArguments().get("count")).intValue(); - ``` - - For complex types (records, enums), use `invocation.getArgumentsAs()` or Jackson's `ObjectMapper.convertValue()`: - ```java - Phase phase = objectMapper.convertValue(invocation.getArguments().get("phase"), Phase.class); - ``` - - **Recommendation:** Generate direct casts for primitives/String, and `ObjectMapper.convertValue()` for enums, records, and complex types. The `ObjectMapper` instance can come from a static field in the generated class. - -**Resolution:** - -The generated `$$CopilotToolMeta` class produces lambdas identical to what `LowLevelToolDefinitionIT` writes by hand. Decisions: - -1. **Access levels:** Require at least package-private. The generated `$$CopilotToolMeta` lives in the same package as the user's class, so package-private and above work without reflection. Emit a compile error for `private` methods annotated with `@CopilotTool`. - -2. **Return type handling:** - - | Return type | Generated code | - |-------------|----------------| - | `String` | `CompletableFuture.completedFuture(instance.method(...))` | - | `CompletableFuture` | `instance.method(...)` (use as-is) | - | `void` | `instance.method(...); return CompletableFuture.completedFuture("Success")` | - | `CompletableFuture` (non-String) | `instance.method(...).thenApply(objectMapper::writeValueAsString)` | - | Other `T` | `CompletableFuture.completedFuture(objectMapper.writeValueAsString(instance.method(...)))` | - -3. **Argument deserialization:** Follow the two patterns proven in `LowLevelToolDefinitionIT`: - - **Simple types** (`String`, primitives, boxed): generate direct cast from `invocation.getArguments().get("name")` (e.g., `(String) args.get("city")`, `((Number) args.get("count")).intValue()`). - - **Complex types** (enums, records, POJOs): generate `objectMapper.convertValue(invocation.getArguments().get("name"), TargetType.class)`. - - **Single-record-parameter shortcut:** When a method has exactly one parameter that is a record/POJO (matching the full argument set), generate `invocation.getArgumentsAs(RecordType.class)` — the same pattern `LowLevelToolDefinitionIT.setPhaseTool` uses. - - The `ObjectMapper` instance: use a `private static final` field in the generated `$$CopilotToolMeta` class, initialized from `com.github.copilot.rpc.RpcMapper.INSTANCE` (already exists in the SDK). - -### 3.6 — `ToolDefinition.fromObject(Object)` registration API - -**Question:** How does the user get from "an object with `@CopilotTool` methods" to a `List`? - -```java -// Primary API — loads generated $$CopilotToolMeta class -List tools = ToolDefinition.fromObject(myToolsInstance); - -// Variant: from class (for static tools) -List tools = ToolDefinition.fromClass(MyTools.class); -``` - -**Implementation:** - -```java -public static List fromObject(Object instance) { - Class clazz = instance.getClass(); - String metaClassName = clazz.getName() + "$$CopilotToolMeta"; - try { - Class metaClass = Class.forName(metaClassName); - Method defs = metaClass.getMethod("definitions", clazz); - return (List) defs.invoke(null, instance); - } catch (ClassNotFoundException e) { - // Fallback: runtime reflection (if we support it) - return fromObjectReflective(instance); - } -} -``` - -**Open question:** Do we want the reflection fallback? It's nice for users who don't run the processor (e.g., scripting, prototyping), but it adds code and the `-parameters` concern. - -**Recommendation:** Implement the reflection fallback but mark it `@CopilotExperimental` separately. The primary path is the generated `$$CopilotToolMeta`. - -**Resolution** we only want the processor approach. - -### 3.7 — `module-info.java` impact - -The SDK uses JPMS. The processor generates classes into the user's module, not the SDK's. But `fromObject()` uses `Class.forName()` which needs the generated class to be accessible. - -**Question:** Does the generated `$$CopilotToolMeta` class in the user's module need to be exported for `fromObject()` to find it? - -**Answer:** No. `Class.forName()` with the caller's classloader works within the same module. And in the typical unnamed-module (classpath) case, everything is accessible. If the user has a named module, the generated class is in the same package as their tools class, so it's accessible. - -**Action:** Verify this works in a simple named-module test. - - - -### 3.8 — Processor registration - -**Question:** How is the new `@CopilotTool` processor registered alongside `CopilotExperimentalProcessor`? - -The existing `META-INF/services/javax.annotation.processing.Processor` lists `CopilotExperimentalProcessor`. Add the new processor to the same file: - -``` -com.github.copilot.CopilotExperimentalProcessor -com.github.copilot.tool.CopilotToolProcessor -``` - -And in `module-info.java`: -```java -provides javax.annotation.processing.Processor - with CopilotExperimentalProcessor, CopilotToolProcessor; -``` - -**No issues expected here** — this is standard JSR 269 multi-processor registration. - ---- - -## Phase 4 — Implementation (the build order) - -After Phase 3 questions are resolved, implement in this order. Each step should be a separately testable commit. - -### 4.1 — Annotations (`@CopilotTool`, `@Param`) - -**What:** Create the two annotation classes. - -**Files to create:** -- `java/src/main/java/com/github/copilot/tool/CopilotTool.java` -- `java/src/main/java/com/github/copilot/tool/Param.java` - -**Tests:** -- Compile-only: ensure they compile, can be applied to methods/parameters, and are annotated with `@CopilotExperimental`. -- No runtime behavior yet. - -**Gating criteria:** `mvn clean compile` passes. - -### 4.2 — Schema generation utility (compile-time) - -**What:** A utility class that, given `javax.lang.model` types, produces the `Map` JSON Schema as a Java source code literal. - -**Files to create:** -- `java/src/main/java/com/github/copilot/tool/SchemaGenerator.java` (compile-time, works with `TypeMirror`) - -**Tests:** -- Unit tests that exercise the type-to-schema mapping with mock `TypeMirror` instances (or integration tests via the annotation processor in a test compilation). - -**Gating criteria:** Can generate correct schema `Map` source code for: `String`, `int`, `boolean`, `double`, `enum`, `List`, a simple record. - -### 4.3 — Annotation processor (`CopilotToolProcessor`) - -**What:** JSR 269 processor that finds `@CopilotTool` methods and generates `$$CopilotToolMeta` classes. - -**Files to create:** -- `java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java` - -**Files to modify:** -- `java/src/main/resources/META-INF/services/javax.annotation.processing.Processor` — add the new processor -- `java/src/main/java/module-info.java` — add `provides` clause and `exports com.github.copilot.tool` - -**Tests:** -- **Compilation tests:** Compile test source files with `@CopilotTool` methods and verify: - - `$$CopilotToolMeta` class is generated - - Generated schema matches expected JSON Schema - - Compile errors emitted for: private methods, unsupported parameter types, duplicate tool names -- Use `javax.tools.JavaCompiler` programmatically (same pattern langchain4j uses for testing annotation processors). - -**Gating criteria:** Processor generates correct `$$CopilotToolMeta` for a class with 2-3 `@CopilotTool` methods. - -### 4.4 — `ToolDefinition.fromObject(Object)` - -**What:** The runtime bridge that loads generated metadata and returns `List`. - -**Files to modify:** -- `java/src/main/java/com/github/copilot/rpc/ToolDefinition.java` — add `fromObject(Object)` and `fromClass(Class)` static methods - -**Tests:** -- Unit test: create a test class with `@CopilotTool` methods, compile it (processor generates metadata), call `fromObject()`, verify the returned `List` has correct names, descriptions, schemas, and working handlers. - -**Gating criteria:** `ToolDefinition.fromObject(new MyTestTools())` returns a list with working tool definitions. - -### 4.5 — E2E integration test - -**What:** An E2E failsafe IT that uses `@CopilotTool` + `ToolDefinition.fromObject()` against the replay proxy. - -**Files to create:** -- `test/snapshots/tools/ergonomic_tool_definition.yaml` — new snapshot (may be identical to `low_level_tool_definition.yaml` since the wire format is the same) -- `java/src/test/java/com/github/copilot/ErgonomicToolDefinitionIT.java` - -**The test will look like:** - -```java -class MyTestTools { - String currentPhase; - - @CopilotTool("Sets the current phase of the agent") - String setCurrentPhase(@Param("The phase to transition to") String phase) { - currentPhase = phase; - return "Phase set to " + phase; - } - - @CopilotTool("Search for items by keyword") - String searchItems(@Param("Search keyword") String keyword) { - return "Found: item_alpha, item_beta"; - } - - @CopilotTool(value = "Custom grep override", name = "grep", overridesBuiltInTool = true) - String grepOverride(@Param("Search query") String query) { - return "CUSTOM_GREP: " + query; - } -} - -@Test -void ergonomicToolDefinition() throws Exception { - MyTestTools tools = new MyTestTools(); - List toolDefs = ToolDefinition.fromObject(tools); - - // ... create session with toolDefs, send prompt, assert same behavior - // as LowLevelToolDefinitionIT -} -``` - -**Gating criteria:** Test passes with the same assertions as `LowLevelToolDefinitionIT` — proving the ergonomic API produces identical behavior to the explicit API. - -### 4.6 — Reflection fallback (optional, can defer) - -**What:** `fromObject()` falls back to runtime reflection when `$$CopilotToolMeta` is not found. - -**Files to create/modify:** -- `java/src/main/java/com/github/copilot/tool/ReflectiveToolScanner.java` -- Modify `ToolDefinition.fromObject()` to call this on `ClassNotFoundException` - -**Tests:** -- Compile a test class WITHOUT the annotation processor, call `fromObject()`, verify it still works (with `-parameters` flag). - -**Gating criteria:** Fallback path produces the same `List` as the processor-generated path. - ---- - -## Phase 5 — Documentation and examples - -- Update `java/README.md` with the ergonomic tool definition example. -- Add a "Tools" section showing both the low-level and high-level APIs. -- Reference ADR-005 for design rationale. - ---- - -## Phase 6 — Port to `add-tests-that-use-ergonomic_tool_definition.yaml.md` - -Same cycle as Phase 2 → `add-tests-that-use-low_level_tool_definition.yaml.md`: once the Java E2E test passes with the ergonomic API, create a prompt to port the test to dotnet/go/nodejs/python/rust. - -**Note:** This may not be applicable — the ergonomic API (`@CopilotTool`) is Java-specific. The other SDKs already have their own ergonomic paths. The snapshot can be shared, but the test code is language-specific by nature. Evaluate whether this phase is needed after Phase 5. - ---- - -## Reference: how langchain4j and Micronaut do it - -### langchain4j (runtime reflection) - -- `@Tool` on methods, `@P` on parameters. -- `ToolSpecifications.toolSpecificationsFrom(Object)` scans methods at runtime. -- `JsonSchemaElementUtils` maps `java.lang.reflect.Type` → JSON Schema. -- `DefaultToolExecutor.executeWithContext()` invokes via `Method.invoke()` with argument coercion. -- Requires `-parameters` javac flag or explicit `@P(name="...")`. -- Source: `langchain4j-core/src/main/java/dev/langchain4j/agent/tool/` - -### Micronaut (compile-time annotation processor) - -- `AbstractInjectAnnotationProcessor` (extends `AbstractProcessor`) is the JSR 269 entry point. -- `TypeElementVisitor` SPI pattern: visitors registered via SPI walk the AST. -- `BeanDefinitionWriter` generates bytecode companion classes (`$Definition`, `$Definition$Exec`). -- `ParameterElement.getName()` at compile time — no `-parameters` flag needed. -- Source: `inject-java/src/main/java/io/micronaut/annotation/processing/` - -### Our approach: langchain4j's API + Micronaut's implementation strategy - -- **User-facing API** matches langchain4j: `@CopilotTool` on methods, `@Param` on parameters, `fromObject()` to discover. -- **Implementation** matches Micronaut: JSR 269 processor generates companion classes at compile time, no runtime reflection in the happy path, no `-parameters` requirement. -- **Fallback** path uses langchain4j-style runtime reflection for users who don't run the processor (prototyping, scripting). diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md index c28b66927..8dfba0354 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -430,3 +430,4 @@ Now let me build and run it. First, I need to ensure `copilot-sdk-java` is insta Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && cd /Users/edburns/workareas/copilot-sdk/java && LOG="$(date +%Y%m%d-%H%M)-job-logs.txt" && mvn install -DskipTests -pl . > "$LOG" 2>&1 & tail -f "$LOG" Made changes. + diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md index a993cd30c..26882a7bb 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md @@ -307,6 +307,18 @@ The SDK uses JPMS. The processor generates classes into the user's module, not t **Action:** Verify this works in a simple named-module test. +**Resolution** + +See `1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml` and files + +``` +src/main/java/com/github/dd3021192/MyTools$$CopilotToolMeta.java +src/main/java/com/github/dd3021192/MyTools.java +src/main/java/com/github/dd3021192/Main.java +``` + +For verificiation. + ### 3.8 — Processor registration **Question:** How is the new `@CopilotTool` processor registered alongside `CopilotExperimentalProcessor`? From ba84711b7cce238516d2f46d8a14268661c15375 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 17:43:57 -0400 Subject: [PATCH 08/29] Resolve 3.8 --- ...3018003-ignorance-reduction-for-implementation-plan.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md index 26882a7bb..cb4b6cd4e 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md @@ -295,7 +295,7 @@ public static List fromObject(Object instance) { **Recommendation:** Implement the reflection fallback but mark it `@CopilotExperimental` separately. The primary path is the generated `$$CopilotToolMeta`. -**Resolution** we only want the processor approach. +**Resolution:** we only want the processor approach. ### 3.7 — `module-info.java` impact @@ -307,7 +307,7 @@ The SDK uses JPMS. The processor generates classes into the user's module, not t **Action:** Verify this works in a simple named-module test. -**Resolution** +**Resolution:** See `1682-java-tool-ergonomics-prompts-remove-before-merge/dd3021192/pom.xml` and files @@ -336,7 +336,7 @@ provides javax.annotation.processing.Processor with CopilotExperimentalProcessor, CopilotToolProcessor; ``` -**No issues expected here** — this is standard JSR 269 multi-processor registration. +**Resolution:** **No issues expected here** — this is standard JSR 269 multi-processor registration. --- @@ -447,6 +447,8 @@ void ergonomicToolDefinition() throws Exception { ### 4.6 — Reflection fallback (optional, can defer) +Per resolution 3.6, we have decided to defer item 4.6. + **What:** `fromObject()` falls back to runtime reflection when `$$CopilotToolMeta` is not found. **Files to create/modify:** From c36ab4c63fa6f53d70afab4dfd32adf9a9f14c9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:18:28 +0000 Subject: [PATCH 09/29] Initial plan From 9c4de05f1fd7cf969389fad2a476f14ac9430476 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:26:45 +0000 Subject: [PATCH 10/29] feat(java): create @CopilotTool and @Param annotations with tests - Add NONE constant to ToolDefer enum for annotation default value - Create com.github.copilot.tool.CopilotTool annotation - Create com.github.copilot.tool.Param annotation - Export com.github.copilot.tool package in module-info.java - Add CopilotToolAnnotationTest verifying retention, targets, defaults Closes github/copilot-sdk#1758 --- .../com/github/copilot/rpc/ToolDefer.java | 6 + .../com/github/copilot/tool/CopilotTool.java | 53 +++++++ .../java/com/github/copilot/tool/Param.java | 47 ++++++ java/src/main/java/module-info.java | 1 + .../tool/CopilotToolAnnotationTest.java | 149 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 java/src/main/java/com/github/copilot/tool/CopilotTool.java create mode 100644 java/src/main/java/com/github/copilot/tool/Param.java create mode 100644 java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java index 1955f02ec..3ff33bfdd 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java @@ -21,6 +21,12 @@ */ public enum ToolDefer { + /** + * No deferral preference set. Used as the default for annotation-based + * tool definitions where the user has not explicitly chosen a mode. + */ + NONE(""), + /** The tool can be deferred and surfaced through tool search. */ AUTO("auto"), diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java new file mode 100644 index 000000000..16093edd4 --- /dev/null +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.github.copilot.CopilotExperimental; +import com.github.copilot.rpc.ToolDefer; + +/** + * Marks a method as a Copilot tool. The annotated method will be exposed to the + * model as a callable tool during a session. + * + *

+ * Example usage: + * + *

+ * @CopilotTool("Get weather for a location")
+ * public CompletableFuture<String> getWeather(
+ *         @Param(value = "City name", required = true) String location) {
+ *     return CompletableFuture.completedFuture("Sunny in " + location);
+ * }
+ * 
+ * + * @since 1.0.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@CopilotExperimental +public @interface CopilotTool { + + /** Tool description (sent to the model). */ + String value(); + + /** Tool name. Defaults to method name converted to snake_case. */ + String name() default ""; + + /** Whether this tool overrides a built-in tool. */ + boolean overridesBuiltInTool() default false; + + /** Whether to skip permission checks. */ + boolean skipPermission() default false; + + /** Defer configuration for this tool. */ + ToolDefer defer() default ToolDefer.NONE; +} diff --git a/java/src/main/java/com/github/copilot/tool/Param.java b/java/src/main/java/com/github/copilot/tool/Param.java new file mode 100644 index 000000000..e3b877bdf --- /dev/null +++ b/java/src/main/java/com/github/copilot/tool/Param.java @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotates a parameter of a {@link CopilotTool}-annotated method to provide + * metadata about the parameter that is sent to the model. + * + *

+ * Example usage: + * + *

+ * @CopilotTool("Search for issues")
+ * public CompletableFuture<String> searchIssues(
+ *         @Param(value = "Search query", required = true) String query,
+ *         @Param(value = "Max results", required = false, defaultValue = "10") int limit) {
+ *     // ...
+ * }
+ * 
+ * + * @since 1.0.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Param { + + /** Parameter description (sent to the model). */ + String value() default ""; + + /** Parameter name override. Defaults to the actual parameter name. */ + String name() default ""; + + /** Whether this parameter is required. Default true. */ + boolean required() default true; + + /** Optional default value when the argument is omitted. */ + String defaultValue() default ""; +} diff --git a/java/src/main/java/module-info.java b/java/src/main/java/module-info.java index 9f48b3747..e3a7bc5fc 100644 --- a/java/src/main/java/module-info.java +++ b/java/src/main/java/module-info.java @@ -19,6 +19,7 @@ exports com.github.copilot.generated; exports com.github.copilot.generated.rpc; exports com.github.copilot.rpc; + exports com.github.copilot.tool; opens com.github.copilot to com.fasterxml.jackson.databind; opens com.github.copilot.generated to com.fasterxml.jackson.databind; diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java new file mode 100644 index 000000000..545434011 --- /dev/null +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.CopilotExperimental; +import com.github.copilot.rpc.ToolDefer; + +/** + * Unit tests for {@link CopilotTool} and {@link Param} annotations. + */ +public class CopilotToolAnnotationTest { + + // --- @CopilotTool attribute verification --- + + @Test + void copilotToolHasRuntimeRetention() { + Retention retention = CopilotTool.class.getAnnotation(Retention.class); + assertNotNull(retention); + assertEquals(RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + void copilotToolTargetsMethod() { + Target target = CopilotTool.class.getAnnotation(Target.class); + assertNotNull(target); + assertArrayEquals(new ElementType[] {ElementType.METHOD}, target.value()); + } + + @Test + void copilotToolIsAnnotatedWithCopilotExperimental() { + // @CopilotExperimental has CLASS retention so it is not visible via + // reflection at runtime. However, we can confirm: + // 1. The annotation type targets TYPE (which includes @interface declarations). + // 2. Compilation succeeded with @CopilotExperimental on @CopilotTool + // (the CopilotExperimentalProcessor would reject usage otherwise). + Target expTarget = CopilotExperimental.class.getAnnotation(Target.class); + assertNotNull(expTarget); + boolean includesType = false; + for (ElementType et : expTarget.value()) { + if (et == ElementType.TYPE) { + includesType = true; + break; + } + } + assertTrue(includesType, + "@CopilotExperimental must target TYPE to be applicable to annotation declarations"); + } + + @Test + void copilotToolDefaultValues() throws Exception { + Method nameMethod = CopilotTool.class.getDeclaredMethod("name"); + assertEquals("", nameMethod.getDefaultValue()); + + Method overridesMethod = CopilotTool.class.getDeclaredMethod("overridesBuiltInTool"); + assertEquals(false, overridesMethod.getDefaultValue()); + + Method skipMethod = CopilotTool.class.getDeclaredMethod("skipPermission"); + assertEquals(false, skipMethod.getDefaultValue()); + + Method deferMethod = CopilotTool.class.getDeclaredMethod("defer"); + assertEquals(ToolDefer.NONE, deferMethod.getDefaultValue()); + } + + // --- @Param attribute verification --- + + @Test + void paramHasRuntimeRetention() { + Retention retention = Param.class.getAnnotation(Retention.class); + assertNotNull(retention); + assertEquals(RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + void paramTargetsParameter() { + Target target = Param.class.getAnnotation(Target.class); + assertNotNull(target); + assertArrayEquals(new ElementType[] {ElementType.PARAMETER}, target.value()); + } + + @Test + void paramDefaultValues() throws Exception { + Method valueMethod = Param.class.getDeclaredMethod("value"); + assertEquals("", valueMethod.getDefaultValue()); + + Method nameMethod = Param.class.getDeclaredMethod("name"); + assertEquals("", nameMethod.getDefaultValue()); + + Method requiredMethod = Param.class.getDeclaredMethod("required"); + assertEquals(true, requiredMethod.getDefaultValue()); + + Method defaultValueMethod = Param.class.getDeclaredMethod("defaultValue"); + assertEquals("", defaultValueMethod.getDefaultValue()); + } + + // --- Applicability test --- + + @SuppressWarnings("unused") + static class SampleToolHolder { + + @CopilotTool(value = "Get weather for a location", name = "get_weather", defer = ToolDefer.AUTO) + public CompletableFuture getWeather( + @Param(value = "City name", required = true) String location, + @Param(value = "Temperature unit", required = false, defaultValue = "celsius") String unit) { + return CompletableFuture.completedFuture("Sunny in " + location); + } + } + + @Test + void annotationsAreAccessibleViaReflection() throws Exception { + Method method = SampleToolHolder.class.getDeclaredMethod("getWeather", String.class, String.class); + + CopilotTool toolAnnotation = method.getAnnotation(CopilotTool.class); + assertNotNull(toolAnnotation); + assertEquals("Get weather for a location", toolAnnotation.value()); + assertEquals("get_weather", toolAnnotation.name()); + assertFalse(toolAnnotation.overridesBuiltInTool()); + assertFalse(toolAnnotation.skipPermission()); + assertEquals(ToolDefer.AUTO, toolAnnotation.defer()); + + Parameter[] params = method.getParameters(); + assertEquals(2, params.length); + + Param locationParam = params[0].getAnnotation(Param.class); + assertNotNull(locationParam); + assertEquals("City name", locationParam.value()); + assertTrue(locationParam.required()); + assertEquals("", locationParam.defaultValue()); + + Param unitParam = params[1].getAnnotation(Param.class); + assertNotNull(unitParam); + assertEquals("Temperature unit", unitParam.value()); + assertFalse(unitParam.required()); + assertEquals("celsius", unitParam.defaultValue()); + } +} From a0c16233b801efb2579d6cd07aa54f1a0c414908 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 19:12:40 -0400 Subject: [PATCH 11/29] spotless --- .../20260622-prompts.md | 306 ++++++++++++++++++ .../com/github/copilot/rpc/ToolDefer.java | 4 +- .../com/github/copilot/tool/CopilotTool.java | 5 +- .../java/com/github/copilot/tool/Param.java | 7 +- .../tool/CopilotToolAnnotationTest.java | 12 +- 5 files changed, 318 insertions(+), 16 deletions(-) diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md index 8dfba0354..39109bee2 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -431,3 +431,309 @@ Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/micro Made changes. +### 17:46 Prompt + +Read `copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md` (the plan) to get the context. + +✅✅Pay very close attention to each **Resolution:** to the questions in `Phase 3 — Ignorance reduction: questions to answer before writing code`. + +In this session, we are creating issues of type **Task** as children of Epic https://github.com/github/copilot-sdk/issues/1682 . These issues correspond to the sub-sections in `## Phase 4 — Implementation (the build order)`. + +I want you to use `gh` to create issues as sub-issues of 1682. + +- I will dispatch these issues by assigning them to an agent. Leave them unassigned for now. + +- The description you put in the issues you create is the specification for the work the agent assigned to the issue must do. Therefore, you need to be very specific and targeted when writing the description. + +- Set the type to **Task**. + +- Each issue will be worked on and resolved in serial. You can assume: + + - All issues will operate in this topic branch of `upstream`: `edburns/1682-java-tool-ergonomics`. + + - The assignee agent has access to the topic branch and, most importantly, to the prompts in the directory `1682-java-tool-ergonomics-prompts-remove-before-merge`, which includes the plan. Therefore, you can instruct the agent to access the plan by reference, you don't need to reference it by value directly in the issue. + +- The sub-sections of `## Phase 4 — Implementation (the build order)` have essential details for each corresponding **Task**. + +- Instruct the agent to read the entire plan before working. + +- Instruct the agent to carefully re-read the relevant sections of the of the plan. ❌❌You can't just say, "read the relevant sections of the plan". ✅✅In the description of the issue, list exactly what sections of the plan apply to this particular issue. + +- Though the sub-sections of `## Phase 4 — Implementation (the build order)` have essential details for each corresponding **Task**, I want you to carefully consider if there additional gating tests you can add so that as the agent proceeds from one task to the next, the likelihood of rework is reduced. + +### 18:16 Response + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#1-1), lines 1 to 100 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#101-101), lines 101 to 200 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#201-201), lines 201 to 300 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#301-301), lines 301 to 400 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#401-401), lines 401 to 500 + +Read [](file:///Users/edburns/workareas/copilot-sdk/1682-java-tool-ergonomics-prompts-remove-before-merge/dd-3018003-ignorance-reduction-for-implementation-plan.md#501-501), lines 501 to 505 + +Now I have the full plan. Let me create the 5 issues (4.1–4.5, skipping 4.6 which is deferred) as sub-issues of #1682. I'll write detailed descriptions with gating tests. + +Ran terminal command: gh issue create --help 2>&1 | head -40 + +`gh issue create` supports `--parent` and `--type` directly. I'll create each issue with a body file. + +Created [](file:///tmp/issue-4.1-body.md) + +Created [](file:///tmp/issue-4.2-body.md) + +Created [](file:///tmp/issue-4.3-body.md) + +Created 5 todos + +Created [](file:///tmp/issue-4.4-body.md) + +Created [](file:///tmp/issue-4.5-body.md) + +Now I'll create the issues using `gh`: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "[Java] @CopilotTool ergonomics 4.1: Create @CopilotTool and @Param annotations" \ + --body-file /tmp/issue-4.1-body.md \ + --type "Task" \ + --parent 1682 + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "[Java] @CopilotTool ergonomics 4.2: Schema generation utility (TypeMirror to JSON Schema)" \ + --body-file /tmp/issue-4.2-body.md \ + --type "Task" \ + --parent 1682 + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "[Java] @CopilotTool ergonomics 4.3: Annotation processor (CopilotToolProcessor)" \ + --body-file /tmp/issue-4.3-body.md \ + --type "Task" \ + --parent 1682 + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "[Java] @CopilotTool ergonomics 4.4: ToolDefinition.fromObject(Object) registration API" \ + --body-file /tmp/issue-4.4-body.md \ + --type "Task" \ + --parent 1682 + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && gh issue create \ + --repo github/copilot-sdk \ + --title "[Java] @CopilotTool ergonomics 4.5: E2E integration test with replay proxy" \ + --body-file /tmp/issue-4.5-body.md \ + --type "Task" \ + --parent 1682 + +All 5 issues created as sub-issues of #1682: + +| Phase | Issue | Title | +|-------|-------|-------| +| 4.1 | [#1758](https://github.com/github/copilot-sdk/issues/1758) | Create `@CopilotTool` and `@Param` annotations | +| 4.2 | [#1759](https://github.com/github/copilot-sdk/issues/1759) | Schema generation utility (TypeMirror → JSON Schema) | +| 4.3 | [#1760](https://github.com/github/copilot-sdk/issues/1760) | Annotation processor (`CopilotToolProcessor`) | +| 4.4 | [#1761](https://github.com/github/copilot-sdk/issues/1761) | `ToolDefinition.fromObject(Object)` registration API | +| 4.5 | [#1762](https://github.com/github/copilot-sdk/issues/1762) | E2E integration test with replay proxy | + +Each issue includes: +- Explicit list of plan sections to re-read (by section number and title) +- Instruction to read the full plan before starting +- Detailed gating tests beyond what the plan specifies (e.g., reflection-based annotation attribute verification in 4.1, handler invocation tests in 4.4) +- Clear prerequisites (serial dependency chain) +- Branch and constraint information + +Updated todo list + +Made changes. + +### 18:37 Prompt + +Consider this text from the plan + +-------- + +**Question:** What attributes does `@CopilotTool` need? + +Based on ADR-005 and the C#/langchain4j comparisons: + +```java +@Documented +@Retention(RetentionPolicy.SOURCE) // only needed at compile time for processor +@Target(ElementType.METHOD) +@CopilotExperimental +public @interface CopilotTool { + /** Tool description (sent to the model). */ + String value(); + + /** Tool name. Defaults to method name converted to snake_case. */ + String name() default ""; + + /** Whether this tool overrides a built-in tool. */ + boolean overridesBuiltInTool() default false; + + /** Whether to skip permission checks. */ + boolean skipPermission() default false; +} +``` + + +------ + +I'm concerned about relying on snake_case here. That's a Python thing. Java uses camelCase. + +What would be the changes to the overall design if we said camelCase instead of snake_case? + +### 18:40 Prompt + +Updated todo list + +Let me check what naming convention the existing SDK and wire protocol actually use for tool names. + +Searched for regex `tool.*name` (`**/test/snapshots/tools/*.yaml`), no results + +Searched for regex `snake_case|snakeCase|toSnakeCase|to_snake` (`**/java/**`), 9 results + +Searched for regex `DefineTool|define_tool|defineTool` (`**/nodejs/src/**`), 4 results + +Read [](file:///Users/edburns/workareas/copilot-sdk/test/snapshots/tools/low_level_tool_definition.yaml) + +Searched for regex `DefineTool|tool.*name` (`**/go/*.go`), 10 results + +Searched for regex `set_current_phase|search_items|tool.*name` (`**/java/src/test/**/*LowLevel*"`), no results + +Searched for regex `set_current_phase|search_items` (`**/java/src/test/**"`), no results + +Searched for regex `tool.*name.*snake|tool.*name.*camel` (`**/nodejs/test/**`), no results + +Searched for regex `set_current_phase|search_items` (`**/java/src/test/**`), 2 results + +Searched for regex `defineTool.*name` (`**/nodejs/samples/**`), no results + +Searched for regex `Name:` (`**/go/samples/**`), 1 result + +Searched for regex `defineTool` (`**/nodejs/examples/**`), 2 results + +Searched for regex `DefineTool|tool.*name` (`**/dotnet/samples/**`), 2 results + +Here's the analysis: + +## The wire protocol uses `snake_case` — it's not just a Python thing + +Looking at the evidence: + +1. **Replay snapshot** (low_level_tool_definition.yaml): `set_current_phase`, `search_items` +2. **Node.js examples**: `defineTool("lookup_fact", ...)` +3. **Go samples**: `Name: "manual_resume_status"` +4. **.NET samples**: `"manual_resume_status"` +5. **Java existing test**: `ToolDefinition.create("set_current_phase", ...)` + +Every SDK across all languages uses `snake_case` for tool names on the wire. This is the **Copilot CLI / model-facing convention** — it's how the LLM identifies and calls tools in function-calling. It's not language-internal naming; it's a wire protocol identifier. + +## Impact of switching to `camelCase` + +If you changed the default conversion to camelCase (or just identity — since Java methods are already camelCase): + +| Aspect | Impact | +|--------|--------| +| **Wire compatibility** | Tool names like `setCurrentPhase` instead of `set_current_phase`. The model still works (it's just a string), but diverges from every other SDK's convention. | +| **Cross-SDK consistency** | A tool that's `set_current_phase` in Node/Go/.NET/Python/Rust would be `setCurrentPhase` in Java — confusing for multi-SDK teams. | +| **Snapshot reuse** | Can't share the same test snapshots across SDKs (model expects specific tool names in responses). | +| **Code change** | Actually simplifies the processor — no case conversion logic needed (identity transform). | +| **Override tools** | Built-in Copilot tools use `snake_case` (like `grep`). Overriding them already requires explicit `name="grep"`, so no impact there. | + +## Recommendation + +Keep `snake_case` as the **default** wire name derivation. The rationale is protocol-level consistency, not Java style: + +- The Java **method** stays camelCase (`setCurrentPhase`) — idiomatic Java. +- The **tool name** sent to the model is `set_current_phase` — idiomatic function-calling protocol. +- Users can always override with `@CopilotTool(name="whateverTheyWant")`. + +This is the same pattern Jackson uses: Java fields are camelCase, but `@JsonProperty` / `PropertyNamingStrategies` controls the wire format. The wire convention is independent of the source language. + +If you still prefer dropping the conversion, the only code changes would be: +1. Remove the snake_case converter from the processor (4.3). +2. Update the test in 4.3 that verifies `setCurrentPhase` → `set_current_phase`. +3. Update the E2E snapshot/test (4.5) tool names. + +### 18:41 Prompt + +Look at what the agent changed in commit 8b899e15254b2b539ae585c85d1e7c39ef864194 to `java/src/main/java/com/github/copilot/rpc/ToolDefer.java`. It added NONE. + +Is there a precedent for this NONE thing in the other languages? + +### 18:43 Response + +Updated todo list + +Ran terminal command: git show 8b899e15254b2b539ae585c85d1e7c39ef864194 -- java/src/main/java/com/github/copilot/rpc/ToolDefer.java + +Searched for regex `ToolDefer|toolDefer|tool_defer|defer` (`**/nodejs/src/**`), 20 results + +Searched for regex `ToolDefer|Defer` (`**/go/**/*.go`), 20 results + +Searched for regex `ToolDefer|Defer` (`**/dotnet/src/**`), 20 results + +Searched for regex `CopilotToolDefer` (`**/dotnet/src/**`), 8 results + +Searched for regex `defer` (`**/nodejs/src/types.ts`), 4 results + +Read [](file:///Users/edburns/workareas/copilot-sdk/dotnet/src/CopilotTool.cs#155-155), lines 155 to 170 + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java#1-1), lines 1 to 10 + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java#20-20), lines 20 to 50 + +**No, there is no precedent for `NONE` in the other languages.** + +- **.NET**: `CopilotToolDefer` has only `Auto` and `Never`. The field is `CopilotToolDefer?` (nullable) — absence is expressed as `null`. +- **Node.js**: `defer?: "auto" | "never"` — absence is expressed as `undefined` (the field is optional). +- **Go**: Would use a pointer or zero value. + +Every other SDK uses **nullability/optionality** to represent "no defer preference." The agent invented `NONE("")` to solve a Java annotation limitation: annotation attributes can't be `null`, so a sentinel value is needed for the default. + +This is a reasonable Java-specific workaround (annotations require compile-time constants), but it introduces a concern: `NONE("")` will serialize as `""` via `@JsonValue` if it's ever accidentally included in a JSON-RPC message. The processor/`fromObject()` code must treat `NONE` as equivalent to `null` (i.e., don't pass it to `ToolDefinition.create()`). You'll want to verify that gate exists in task 4.3/4.4. + +### 18:45 Prompt + +Let's take .NET. What is the behavior of the tool in the "absence" case? And what does that serialize as? + +### 18:47 Response + + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk/dotnet/src/CopilotTool.cs#140-140), lines 140 to 160 + +Read [](file:///Users/edburns/workareas/copilot-sdk/dotnet/src/Client.cs#2415-2415), lines 2415 to 2450 + +Now let me see how `ToolDefinition` serializes when `Defer` is null: + +Searched for regex `ToolDefinition.*serial|WriteJson|JsonSerializ|toolDefinition.*json|RegisterTools|registerTools` (`**/dotnet/src/Client.cs`), 20 results + +Read [](file:///Users/edburns/workareas/copilot-sdk/dotnet/src/Client.cs#2117-2117), lines 2117 to 2130 + +There's the answer: + +**In .NET, when `Defer` is `null`, the key is simply omitted from the JSON.** + +The serializer uses `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`, so any `null`-valued property (like `Defer`, `OverridesBuiltInTool`, `SkipPermission`) is not written to the wire at all. The CLI receives a `ToolDefinition` JSON object with no `defer` key — meaning "no preference, use server default." + +So the wire behavior for "absence" is: **the `defer` field does not appear in the JSON-RPC message**. + +--- + +For Java's `NONE("")`: if Jackson serializes it, it would emit `"defer": ""` — which is **not the same** as omitting the key. The Java code must treat `NONE` as "do not serialize this field" (i.e., pass `null` to `ToolDefinition.create()` for the defer parameter). The `@JsonValue` of `""` is a landmine if it ever leaks to the wire. + +### 19:11 Prompt + +For all the created issues please find the right place to add the following requirement: + +✅✅ YOU MUST run `mvn spotless:apply` before every commit. diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java index 3ff33bfdd..3486a8d18 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java @@ -22,8 +22,8 @@ public enum ToolDefer { /** - * No deferral preference set. Used as the default for annotation-based - * tool definitions where the user has not explicitly chosen a mode. + * No deferral preference set. Used as the default for annotation-based tool + * definitions where the user has not explicitly chosen a mode. */ NONE(""), diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java index 16093edd4..92347259f 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -22,9 +22,8 @@ * *
  * @CopilotTool("Get weather for a location")
- * public CompletableFuture<String> getWeather(
- *         @Param(value = "City name", required = true) String location) {
- *     return CompletableFuture.completedFuture("Sunny in " + location);
+ * public CompletableFuture<String> getWeather(@Param(value = "City name", required = true) String location) {
+ * 	return CompletableFuture.completedFuture("Sunny in " + location);
  * }
  * 
* diff --git a/java/src/main/java/com/github/copilot/tool/Param.java b/java/src/main/java/com/github/copilot/tool/Param.java index e3b877bdf..56d513d84 100644 --- a/java/src/main/java/com/github/copilot/tool/Param.java +++ b/java/src/main/java/com/github/copilot/tool/Param.java @@ -19,10 +19,9 @@ * *
  * @CopilotTool("Search for issues")
- * public CompletableFuture<String> searchIssues(
- *         @Param(value = "Search query", required = true) String query,
- *         @Param(value = "Max results", required = false, defaultValue = "10") int limit) {
- *     // ...
+ * public CompletableFuture<String> searchIssues(@Param(value = "Search query", required = true) String query,
+ * 		@Param(value = "Max results", required = false, defaultValue = "10") int limit) {
+ * 	// ...
  * }
  * 
* diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java index 545434011..56b64b19f 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java @@ -37,7 +37,7 @@ void copilotToolHasRuntimeRetention() { void copilotToolTargetsMethod() { Target target = CopilotTool.class.getAnnotation(Target.class); assertNotNull(target); - assertArrayEquals(new ElementType[] {ElementType.METHOD}, target.value()); + assertArrayEquals(new ElementType[]{ElementType.METHOD}, target.value()); } @Test @@ -46,7 +46,7 @@ void copilotToolIsAnnotatedWithCopilotExperimental() { // reflection at runtime. However, we can confirm: // 1. The annotation type targets TYPE (which includes @interface declarations). // 2. Compilation succeeded with @CopilotExperimental on @CopilotTool - // (the CopilotExperimentalProcessor would reject usage otherwise). + // (the CopilotExperimentalProcessor would reject usage otherwise). Target expTarget = CopilotExperimental.class.getAnnotation(Target.class); assertNotNull(expTarget); boolean includesType = false; @@ -56,8 +56,7 @@ void copilotToolIsAnnotatedWithCopilotExperimental() { break; } } - assertTrue(includesType, - "@CopilotExperimental must target TYPE to be applicable to annotation declarations"); + assertTrue(includesType, "@CopilotExperimental must target TYPE to be applicable to annotation declarations"); } @Test @@ -88,7 +87,7 @@ void paramHasRuntimeRetention() { void paramTargetsParameter() { Target target = Param.class.getAnnotation(Target.class); assertNotNull(target); - assertArrayEquals(new ElementType[] {ElementType.PARAMETER}, target.value()); + assertArrayEquals(new ElementType[]{ElementType.PARAMETER}, target.value()); } @Test @@ -112,8 +111,7 @@ void paramDefaultValues() throws Exception { static class SampleToolHolder { @CopilotTool(value = "Get weather for a location", name = "get_weather", defer = ToolDefer.AUTO) - public CompletableFuture getWeather( - @Param(value = "City name", required = true) String location, + public CompletableFuture getWeather(@Param(value = "City name", required = true) String location, @Param(value = "Temperature unit", required = false, defaultValue = "celsius") String unit) { return CompletableFuture.completedFuture("Sunny in " + location); } From 1a5778a699cb9e985b83c46411ce6fa883d26e4f Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 19:32:47 -0400 Subject: [PATCH 12/29] fix(java): make ToolDefer.NONE serialize as null to prevent wire leak NONE is an annotation-only sentinel for @CopilotTool(defer=...) defaults. Its @JsonValue now returns null so @JsonInclude(NON_NULL) omits it from the JSON-RPC payload, matching the nullable/optional semantics used by all other SDKs (.NET CopilotToolDefer?, Node defer?, Go omitempty, Python | None, Rust Option). --- .../com/github/copilot/rpc/ToolDefer.java | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java index 3486a8d18..5b9e115c7 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java @@ -22,8 +22,18 @@ public enum ToolDefer { /** - * No deferral preference set. Used as the default for annotation-based tool - * definitions where the user has not explicitly chosen a mode. + * No deferral preference set. This is an annotation-only sentinel used + * as the default for {@code @CopilotTool(defer = ToolDefer.NONE)}. + *

+ * This constant must not be passed to {@link ToolDefinition} factory + * methods. The annotation processor and {@code ToolDefinition.fromObject()} + * must map {@code NONE} to {@code null} so the {@code defer} field is omitted + * from the JSON-RPC wire payload (matching the nullable/optional semantics used + * by all other SDKs). + *

+ * As a safety net, {@link #getValue()} returns {@code null} for this constant, + * so {@code @JsonInclude(NON_NULL)} will omit it even if it accidentally + * reaches serialization. */ NONE(""), @@ -41,12 +51,16 @@ public enum ToolDefer { /** * Returns the JSON value for this deferral mode. + *

+ * Returns {@code null} for {@link #NONE} so that {@code @JsonInclude(NON_NULL)} + * omits it from the wire payload. * - * @return the string value used in JSON serialization + * @return the string value used in JSON serialization, or {@code null} for + * {@link #NONE} */ @JsonValue public String getValue() { - return value; + return this == NONE ? null : value; } /** From 3865f46067ebb17b61631c219fd3b6d3cca3cd4a Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 22 Jun 2026 19:34:07 -0400 Subject: [PATCH 13/29] WIP Phase 4.1 --- .../20260622-prompts.md | 271 ++++++++++++++++++ .../com/github/copilot/rpc/ToolDefer.java | 23 +- 2 files changed, 286 insertions(+), 8 deletions(-) diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md index 39109bee2..5f453e841 100644 --- a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260622-prompts.md @@ -737,3 +737,274 @@ For Java's `NONE("")`: if Jackson serializes it, it would emit `"defer": ""` — For all the created issues please find the right place to add the following requirement: ✅✅ YOU MUST run `mvn spotless:apply` before every commit. + +and add it. + +### 19:13 Response + + +Updated todo list + +Ran terminal command: gh issue view 1758 --repo github/copilot-sdk --json body -q .body | tail -20 + +I'll add the requirement to the Constraints section of each issue. The best placement is at the top of Constraints since it's a workflow mandate: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && for ISSUE in 1758 1759 1760 1761 1762; do + BODY=$(gh issue view "$ISSUE" --repo github/copilot-sdk --json body -q .body) + UPDATED=$(echo "$BODY" | sed 's/## Constraints/## Constraints\n\n- ✅✅ YOU MUST run `mvn spotless:apply` before every commit./') + echo "$UPDATED" | gh issue edit "$ISSUE" --repo github/copilot-sdk --body-file - + echo "Updated issue #$ISSUE" +done + +Done. All 5 issues (#1758–#1762) now have `✅✅ YOU MUST run mvn spotless:apply before every commit.` as the first bullet in their Constraints section. + +Created 1 todo + +### 19:13 Prompt + + +Ugh, I did a git problem. + +``` +edburns@Edwards-MacBook-Pro copilot-sdk % git push upstream +git push upstream +To github.com:github/copilot-sdk.git + ! [rejected] edburns/1682-java-tool-ergonomics -> edburns/1682-java-tool-ergonomics (non-fast-forward) +error: failed to push some refs to 'github.com:github/copilot-sdk.git' +hint: Updates were rejected because the tip of your current branch is behind +hint: its remote counterpart. If you want to integrate the remote changes, +hint: use 'git pull' before pushing again. +``` + +Please fix it and push. Make sure not to overwrite anything. + +### 19:16 Response + +Updated todo list + +Ran terminal command: git log --oneline -5 + +Ran terminal command: git log --oneline -5 upstream/edburns/1682-java-tool-ergonomics + +Ran terminal command: git fetch upstream edburns/1682-java-tool-ergonomics && git log --oneline upstream/edburns/1682-java-tool-ergonomics..HEAD + +Ran terminal command: git log --oneline HEAD..upstream/edburns/1682-java-tool-ergonomics + +Ran terminal command: git merge-base HEAD upstream/edburns/1682-java-tool-ergonomics + +Ran terminal command: git log --oneline d15cfcbe -1 + +Ran terminal command: echo "Local tree (Resolve 3.8):" && git cat-file -p e9c978bb | grep tree && echo "Remote tree (Resolve 3.8):" && git cat-file -p 622e3ae0 | grep tree + +Ran terminal command: git diff upstream/edburns/1682-java-tool-ergonomics..HEAD --stat + +Ran terminal command: git diff HEAD..upstream/edburns/1682-java-tool-ergonomics --stat + +The remote is strictly a subset of local — it just has deletions (all the same files local added). The local branch has everything the remote has plus more. A safe rebase will work: + +Ran terminal command: git rebase upstream/edburns/1682-java-tool-ergonomics + +Ran terminal command: git stash && git rebase upstream/edburns/1682-java-tool-ergonomics + +Ran terminal command: git stash pop && git log --oneline -5 + +Rebase succeeded — local is now 4 commits ahead of remote with no divergence. Now pushing: + +Ran terminal command: git push upstream edburns/1682-java-tool-ergonomics + +Fixed. The rebase detected that your local commits (with different hashes but same content) were duplicates of what was already on the remote, skipped them, and replayed only the unique commits on top. Push succeeded as a fast-forward. + +Created 3 todos + +### 19:16 Prompt + +Did you push it to upstream? + +### 19:17 Response + + +Updated todo list + + +Yes. The output confirms it pushed to `upstream`: + +``` +To github.com:github/copilot-sdk.git + 622e3ae0..170d5aaa edburns/1682-java-tool-ergonomics -> edburns/1682-java-tool-ergonomics +``` + +### 19:17 Prompt + + + +I don't see the commits at https://github.com/github/copilot-sdk/pull/1763/commits . + +Why? + +### 19:18 Response + +Updated todo list + +Ran terminal command: gh pr view 1763 --repo github/copilot-sdk --json headRefName,baseRefName,headRepository + +PR #1763 is tracking branch `copilot/edburns1682-java-tool-ergonomics`, but you pushed to `edburns/1682-java-tool-ergonomics`. They're different branches. + +You either need to: +1. Push to the PR's branch: `git push upstream edburns/1682-java-tool-ergonomics:copilot/edburns1682-java-tool-ergonomics` +2. Or update the PR to point to your branch instead. + +Which would you prefer? + +### 19:18 Prompt + +We should always and only be pushing to the PR's branch. + +Updated todo list + +Ran terminal command: git push upstream edburns/1682-java-tool-ergonomics:copilot/edburns1682-java-tool-ergonomics + +Ran terminal command: git fetch upstream copilot/edburns1682-java-tool-ergonomics && git log --oneline upstream/copilot/edburns1682-java-tool-ergonomics -5 + +Ran terminal command: git log --oneline HEAD -5 + +Ran terminal command: git diff upstream/copilot/edburns1682-java-tool-ergonomics..HEAD --stat | tail -5 + +Ran terminal command: git diff HEAD..upstream/copilot/edburns1682-java-tool-ergonomics --stat + +Local is a strict superset — the reverse diff only shows deletions (content local has that the PR branch doesn't). Nothing would be lost. This needs a force push since the histories diverged. + +Analyzing your answers... + +Ran terminal command: git push upstream edburns/1682-java-tool-ergonomics:copilot/edburns1682-java-tool-ergonomics --force-with-lease + +Done. PR #1763 now shows the commits at `https://github.com/github/copilot-sdk/pull/1763/commits`. + +Created 2 todos + +### 19:24 Prompt + +Now there are ton of files in the **Files changed** tab that are not at all related to this PR: + +https://github.com/github/copilot-sdk/pull/1763 + +Please fix that so only the files relevant to our work are in there. + +Updated todo list + +Ran terminal command: git log --oneline upstream/main..HEAD + +Ran terminal command: git diff upstream/main..HEAD --stat | tail -40 + +Ran terminal command: git show 5c360d56 --stat + +Ran terminal command: git fetch upstream main && git log --oneline upstream/main -3 + +The branch has a duplicate of commit `a3dbdd19` (already on main) with a different hash (`5c360d56`). Rebasing onto current `upstream/main` will drop it: + +Ran terminal command: git stash && git rebase upstream/main + +Git skipped the duplicate. Let me verify and push: + +Ran terminal command: git diff upstream/main..HEAD --stat && echo "---" && git log --oneline upstream/main..HEAD + +Only Java and prompt files now — no dotnet/go/nodejs/python/rust files. Pushing: + +Ran terminal command: git push upstream edburns/1682-java-tool-ergonomics:copilot/edburns1682-java-tool-ergonomics --force-with-lease && git stash pop + +Done. The PR's "Files changed" tab should now show only the 19 relevant files — all Java source, prompts, and plan files. The unrelated dotnet/go/nodejs/python/rust changes from the duplicate commit are gone. + +Created 2 todos + +### 19:29 Prompt + +Now we come back to the most important thing to fix, that `NONE` field in `java/src/main/java/com/github/copilot/rpc/ToolDefer.java`. + +Read this comment from a reviewer: https://github.com/github/copilot-sdk/pull/1763#discussion_r3455952418 + +And recall what we elicited earlier: + +> Let's take .NET. What is the behavior of the tool in the "absence" case? And what does that serialize as? + +> There's the answer: + +> **In .NET, when `Defer` is `null`, the key is simply omitted from the JSON.** +> +> The serializer uses `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`, so any `null`-valued property (like `Defer`, `OverridesBuiltInTool`, `SkipPermission`) is not written to the wire at all. The CLI receives a `ToolDefinition` JSON object with no `defer` key — meaning "no preference, use server default." +> +> So the wire behavior for "absence" is: **the `defer` field does not appear in the JSON-RPC message**. +> +> --- +> +> For Java's `NONE("")`: if Jackson serializes it, it would emit `"defer": ""` — which is **not the same** as omitting the key. The Java code must treat `NONE` as "do not serialize this field" (i.e., pass `null` to `ToolDefinition.create()` for the defer parameter). The `@JsonValue` of `""` is a landmine if it ever leaks to the wire. + +It seems to me at this point the best thing is to update the Javadoc so that the future work to implement the annotation processor doesn't cause the `defer` to leak onto the wire. + +Is there any other better way at this point? + +### 19:31 Response + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java#1-1), lines 1 to 70 + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java#70-70), lines 70 to 76 + + + +Fetched https://github.com/github/copilot-sdk/pull/1763#discussion_r3455952418 + +There's a better option than Javadoc alone: make `NONE` serialize as `null` instead of `""`. This provides a safety net at the Jackson level: + +```java +@JsonValue +public String getValue() { + return this == NONE ? null : value; +} +``` + +With this change, even if `ToolDefer.NONE` accidentally reaches a `ToolDefinition`, Jackson serializes it as `null`, and `@JsonInclude(NON_NULL)` (already on `ToolDefinition`) omits it from the wire. The bug becomes impossible regardless of what the processor does. + +The internal `""` string is still needed because Java annotations require compile-time constants (`null` can't be used as an annotation element default), but the `@JsonValue` doesn't have to expose it. + +**Tradeoff:** + +| Approach | Prevents wire leak? | Relies on downstream code? | +|----------|---------------------|---------------------------| +| Javadoc only | No — still serializes as `""` if leaked | Yes — processor must map to `null` | +| `@JsonValue` returns `null` for NONE | Yes — impossible to emit `""` | No — defense in depth | + +I'd recommend doing **both**: the `@JsonValue` fix for safety, plus a Javadoc update explaining `NONE` is an annotation-only sentinel. Want me to make those changes? + +Created 1 todo + +### 19:31 Prompt + +Yes. Please do that and commit it. But do not push it. + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java#22-22), lines 22 to 55 + +Replacing 14 lines with 28 lines in [](file:///Users/edburns/workareas/copilot-sdk/java/src/main/java/com/github/copilot/rpc/ToolDefer.java) + + +``` + +``` + + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk/java && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && mvn spotless:apply -q + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk && git add java/src/main/java/com/github/copilot/rpc/ToolDefer.java && git commit -m "fix(java): make ToolDefer.NONE serialize as null to prevent wire leak + +NONE is an annotation-only sentinel for @CopilotTool(defer=...) defaults. +Its @JsonValue now returns null so @JsonInclude(NON_NULL) omits it from +the JSON-RPC payload, matching the nullable/optional semantics used by +all other SDKs (.NET CopilotToolDefer?, Node defer?, Go omitempty, +Python | None, Rust Option)." + +Committed as `b8ad8264`. Spotless check passed. Not pushed. + +Created 1 todo + +Made changes. diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java index 5b9e115c7..ba888ca97 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefer.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefer.java @@ -27,13 +27,18 @@ public enum ToolDefer { *

* This constant must not be passed to {@link ToolDefinition} factory * methods. The annotation processor and {@code ToolDefinition.fromObject()} - * must map {@code NONE} to {@code null} so the {@code defer} field is omitted - * from the JSON-RPC wire payload (matching the nullable/optional semantics used - * by all other SDKs). + * must map {@code NONE} to a {@code null} field reference so that + * {@code @JsonInclude(NON_NULL)} on {@link ToolDefinition} omits the + * {@code defer} key from the JSON-RPC wire payload entirely (matching the + * nullable/optional semantics used by all other SDKs). *

- * As a safety net, {@link #getValue()} returns {@code null} for this constant, - * so {@code @JsonInclude(NON_NULL)} will omit it even if it accidentally - * reaches serialization. + * As a secondary safety net, {@link #getValue()} returns {@code null} for this + * constant. Note that this alone does not cause field omission: if a + * non-null {@code NONE} reference reaches a {@link ToolDefinition} field, + * Jackson's {@code @JsonInclude(NON_NULL)} will still emit the field (as + * {@code "defer": null}) because the field reference itself is not null. The + * primary protection is mapping {@code NONE} to a null field reference before + * constructing the {@link ToolDefinition}. */ NONE(""), @@ -52,8 +57,10 @@ public enum ToolDefer { /** * Returns the JSON value for this deferral mode. *

- * Returns {@code null} for {@link #NONE} so that {@code @JsonInclude(NON_NULL)} - * omits it from the wire payload. + * Returns {@code null} for {@link #NONE} to avoid emitting an empty string + * ({@code "defer": ""}) if this sentinel accidentally reaches serialization. + * With {@code null}, the worst-case leak becomes {@code "defer": null} rather + * than an invalid empty string. * * @return the string value used in JSON serialization, or {@code null} for * {@link #NONE} From 9f78437eae4392b05dacf2c62d62e73b00f1a9a3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:48:15 -0400 Subject: [PATCH 14/29] feat(java): create @CopilotTool and @Param annotations (#1763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP Phase 4.1 * Remove prompts, pre-merge * fix(java): correct ToolDefer.NONE Javadoc on @JsonValue null semantics Clarify that @JsonValue returning null does not cause field omission by @JsonInclude(NON_NULL) — it only changes the leak from "" to null. The primary protection is mapping NONE to a null field reference before constructing ToolDefinition (responsibility of the annotation processor and ToolDefinition.fromObject()). * fix(java): address three review comments Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * Revert "Remove prompts, pre-merge" This reverts commit a4fe9b270e0a796a2510d37ded05581d13c8c746. --------- Co-authored-by: Ed Burns Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/tool/CopilotTool.java | 2 +- .../java/com/github/copilot/tool/Param.java | 2 +- .../tool/CopilotToolAnnotationTest.java | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java index 92347259f..9cde49b20 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java @@ -27,7 +27,7 @@ * } * * - * @since 1.0.0 + * @since 1.0.2 */ @Documented @Retention(RetentionPolicy.RUNTIME) diff --git a/java/src/main/java/com/github/copilot/tool/Param.java b/java/src/main/java/com/github/copilot/tool/Param.java index 56d513d84..75f359dd8 100644 --- a/java/src/main/java/com/github/copilot/tool/Param.java +++ b/java/src/main/java/com/github/copilot/tool/Param.java @@ -25,7 +25,7 @@ * } * * - * @since 1.0.0 + * @since 1.0.2 */ @Documented @Retention(RetentionPolicy.RUNTIME) diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java index 56b64b19f..9052c6b1c 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolAnnotationTest.java @@ -6,12 +6,14 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.InputStream; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Method; import java.lang.reflect.Parameter; +import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.Test; @@ -41,12 +43,7 @@ void copilotToolTargetsMethod() { } @Test - void copilotToolIsAnnotatedWithCopilotExperimental() { - // @CopilotExperimental has CLASS retention so it is not visible via - // reflection at runtime. However, we can confirm: - // 1. The annotation type targets TYPE (which includes @interface declarations). - // 2. Compilation succeeded with @CopilotExperimental on @CopilotTool - // (the CopilotExperimentalProcessor would reject usage otherwise). + void copilotExperimentalTargetsTypeForAnnotationDeclarations() { Target expTarget = CopilotExperimental.class.getAnnotation(Target.class); assertNotNull(expTarget); boolean includesType = false; @@ -59,6 +56,16 @@ void copilotToolIsAnnotatedWithCopilotExperimental() { assertTrue(includesType, "@CopilotExperimental must target TYPE to be applicable to annotation declarations"); } + @Test + void copilotToolDeclaresCopilotExperimentalInClassFile() throws Exception { + String classFileResourcePath = "/" + CopilotTool.class.getName().replace('.', '/') + ".class"; + try (InputStream classFile = CopilotTool.class.getResourceAsStream(classFileResourcePath)) { + assertNotNull(classFile, "CopilotTool class file must be readable as a resource"); + String classFileText = new String(classFile.readAllBytes(), StandardCharsets.ISO_8859_1); + assertTrue(classFileText.contains("com/github/copilot/CopilotExperimental")); + } + } + @Test void copilotToolDefaultValues() throws Exception { Method nameMethod = CopilotTool.class.getDeclaredMethod("name"); From 5ec1aee306cfade4c16d703f180bd21395fd9653 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:18:28 +0000 Subject: [PATCH 15/29] Initial plan From f413c7f6d0d47b5741d4e5fe526184280764a9f7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:06:51 -0400 Subject: [PATCH 16/29] feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility (#1766) * Initial plan * feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility Creates SchemaGenerator.java that maps javax.lang.model TypeMirror instances to JSON Schema source code literals (Map.of(...) expressions). Implements all 24 type mappings from the specification including: - Primitives and boxed types (int/Integer, long/Long, etc.) - String, UUID, OffsetDateTime - Collections (List, Collection, Set) - Maps (Map with typed values) - Arrays (String[]) - Enums (with constant enumeration) - Records and POJOs (with properties/required) - Optional, OptionalInt, OptionalDouble - Sealed interfaces (oneOf) - JsonNode and Object (any) Also adds SchemaGeneratorTest using compilation-testing approach with javax.tools.JavaCompiler to exercise the generator at compile time. Closes github/copilot-sdk#1759 * fix: address code review - remove unused param, handle all primitive types * fix(java): correct SimpleJavaFileObject override - getCharContent not getContent Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * spotless * Remove .class files generated by test * spotless * fix: use Map.ofEntries for properties to avoid Map.of 10-entry limit Address review comment r3461777483: Map.of() only supports up to 10 key-value pairs. Switch properties maps in SchemaGenerator to use Map.ofEntries(Map.entry(...), ...) so records/POJOs/methods with >10 fields won't cause generated source compilation failures. Update SchemaGeneratorTest expectations to match the new format. * fix: add missing Byte/Short/Character boxed type mappings Address review comment r3461777428: Byte and Short now map to "integer", Character maps to "string", matching their primitive equivalents. Add tests for all three. * fix: add missing OptionalLong mapping in generateDeclaredTypeSchema Address review comment r3461777459: OptionalLong was handled in isOptionalType/unwrapOptional but missing from generateDeclaredTypeSchema, causing it to fall through to POJO introspection when used as a direct return type. Add the mapping and tests for OptionalInt, OptionalLong, and OptionalDouble. * fix: correct misleading @JsonSubTypes comment on sealed interface handling Address review comment r3461777579: the implementation uses getPermittedSubclasses() (Java sealed types), not Jackson annotations. * test: add sealed interface test for oneOf schema generation Address review comment r3461777685: the processor had special handling for TestSealed* types but no test exercised generateSealedSchema(). Add a test with a sealed interface (TestSealedShape) and two record permits (Circle, Rect) verifying the oneOf schema output. * test: add >10-field record test proving Map.ofEntries compiles Address review comment r3461777706: add a test with an 11-component record that verifies the generated Map.ofEntries(...) expression actually compiles, proving the Map.of 10-entry limit fix works end-to-end. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com> Co-authored-by: Ed Burns --- java/mvnw | 0 .../github/copilot/tool/SchemaGenerator.java | 384 ++++++++++ .../copilot/tool/SchemaGeneratorTest.java | 700 ++++++++++++++++++ 3 files changed, 1084 insertions(+) mode change 100644 => 100755 java/mvnw create mode 100644 java/src/main/java/com/github/copilot/tool/SchemaGenerator.java create mode 100644 java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java diff --git a/java/mvnw b/java/mvnw old mode 100644 new mode 100755 diff --git a/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java b/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java new file mode 100644 index 000000000..f2c92df85 --- /dev/null +++ b/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.RecordComponentElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.ArrayType; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; + +/** + * Compile-time utility that maps {@code javax.lang.model} types to JSON Schema + * represented as Java source code literals ({@code Map.of(...)} expressions). + * + *

+ * This class is invoked by the annotation processor and operates exclusively + * with the {@code javax.lang.model} API. It does NOT use + * {@code java.lang.reflect}. + * + * @since 1.0.2 + */ +public class SchemaGenerator { + + /** + * Given a {@link TypeMirror} from the annotation processing environment, + * returns a {@code String} containing Java source code for a {@code Map} + * literal representing the JSON Schema of that type. + * + * @param type + * the type to generate schema for + * @param typeUtils + * the {@link Types} utility from the processing environment + * @param elementUtils + * the {@link Elements} utility from the processing environment + * @return a Java source code string representing the JSON Schema + */ + public String generateSchemaSource(TypeMirror type, Types typeUtils, Elements elementUtils) { + return generateSchema(type, typeUtils, elementUtils); + } + + /** + * Generates the full "parameters" schema source for a method's parameters. + * Produces a + * {@code Map.of("type", "object", "properties", Map.of(...), "required", List.of(...))}. + * + * @param parameters + * the method parameters to generate schema for + * @param typeUtils + * the {@link Types} utility from the processing environment + * @param elementUtils + * the {@link Elements} utility from the processing environment + * @return a Java source code string representing the parameters JSON Schema + */ + public String generateParametersSchemaSource(List parameters, Types typeUtils, + Elements elementUtils) { + if (parameters.isEmpty()) { + return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())"; + } + + List propertyEntries = new ArrayList<>(); + List requiredNames = new ArrayList<>(); + + for (VariableElement param : parameters) { + String paramName = param.getSimpleName().toString(); + TypeMirror paramType = param.asType(); + + boolean isOptional = isOptionalType(paramType, typeUtils, elementUtils); + String schema; + if (isOptional) { + schema = generateSchema(unwrapOptional(paramType, typeUtils, elementUtils), typeUtils, elementUtils); + } else { + schema = generateSchema(paramType, typeUtils, elementUtils); + } + + propertyEntries.add("Map.entry(\"" + paramName + "\", " + schema + ")"); + + if (!isOptional) { + Param paramAnnotation = param.getAnnotation(Param.class); + if (paramAnnotation == null || paramAnnotation.required()) { + requiredNames.add("\"" + paramName + "\""); + } + } + } + + String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; + String required = "List.of(" + String.join(", ", requiredNames) + ")"; + + return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; + } + + private String generateSchema(TypeMirror type, Types typeUtils, Elements elementUtils) { + // Handle primitive types + if (type.getKind().isPrimitive()) { + return generatePrimitiveSchema(type.getKind()); + } + + // Handle array types + if (type.getKind() == TypeKind.ARRAY) { + ArrayType arrayType = (ArrayType) type; + TypeMirror componentType = arrayType.getComponentType(); + String itemsSchema = generateSchema(componentType, typeUtils, elementUtils); + return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")"; + } + + // Handle declared types (classes, interfaces, enums, records) + if (type.getKind() == TypeKind.DECLARED) { + return generateDeclaredTypeSchema((DeclaredType) type, typeUtils, elementUtils); + } + + // Fallback: any + return "Map.of()"; + } + + private String generatePrimitiveSchema(TypeKind kind) { + switch (kind) { + case INT : + case LONG : + case BYTE : + case SHORT : + return "Map.of(\"type\", \"integer\")"; + case DOUBLE : + case FLOAT : + return "Map.of(\"type\", \"number\")"; + case BOOLEAN : + return "Map.of(\"type\", \"boolean\")"; + case CHAR : + return "Map.of(\"type\", \"string\")"; + default : + return "Map.of()"; + } + } + + private String generateDeclaredTypeSchema(DeclaredType type, Types typeUtils, Elements elementUtils) { + TypeElement typeElement = (TypeElement) type.asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + + // String + if ("java.lang.String".equals(qualifiedName)) { + return "Map.of(\"type\", \"string\")"; + } + + // Boxed primitives + if ("java.lang.Integer".equals(qualifiedName) || "java.lang.Long".equals(qualifiedName) + || "java.lang.Byte".equals(qualifiedName) || "java.lang.Short".equals(qualifiedName)) { + return "Map.of(\"type\", \"integer\")"; + } + if ("java.lang.Double".equals(qualifiedName) || "java.lang.Float".equals(qualifiedName)) { + return "Map.of(\"type\", \"number\")"; + } + if ("java.lang.Boolean".equals(qualifiedName)) { + return "Map.of(\"type\", \"boolean\")"; + } + if ("java.lang.Character".equals(qualifiedName)) { + return "Map.of(\"type\", \"string\")"; + } + + // UUID + if ("java.util.UUID".equals(qualifiedName)) { + return "Map.of(\"type\", \"string\", \"format\", \"uuid\")"; + } + + // OffsetDateTime + if ("java.time.OffsetDateTime".equals(qualifiedName)) { + return "Map.of(\"type\", \"string\", \"format\", \"date-time\")"; + } + + // JsonNode (any) + if ("com.fasterxml.jackson.databind.JsonNode".equals(qualifiedName)) { + return "Map.of()"; + } + + // Object (any) + if ("java.lang.Object".equals(qualifiedName)) { + return "Map.of()"; + } + + // Optional types + if ("java.util.Optional".equals(qualifiedName)) { + List typeArgs = type.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return generateSchema(typeArgs.get(0), typeUtils, elementUtils); + } + return "Map.of()"; + } + if ("java.util.OptionalInt".equals(qualifiedName)) { + return "Map.of(\"type\", \"integer\")"; + } + if ("java.util.OptionalDouble".equals(qualifiedName)) { + return "Map.of(\"type\", \"number\")"; + } + if ("java.util.OptionalLong".equals(qualifiedName)) { + return "Map.of(\"type\", \"integer\")"; + } + + // List / Collection + if (isCollectionType(qualifiedName)) { + List typeArgs = type.getTypeArguments(); + if (!typeArgs.isEmpty()) { + String itemsSchema = generateSchema(typeArgs.get(0), typeUtils, elementUtils); + return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")"; + } + return "Map.of(\"type\", \"array\")"; + } + + // Map + if (isMapType(qualifiedName)) { + List typeArgs = type.getTypeArguments(); + if (typeArgs.size() == 2) { + TypeMirror valueType = typeArgs.get(1); + if (valueType.getKind() == TypeKind.DECLARED) { + TypeElement valueElement = (TypeElement) ((DeclaredType) valueType).asElement(); + String valueQName = valueElement.getQualifiedName().toString(); + if ("java.lang.Object".equals(valueQName)) { + return "Map.of(\"type\", \"object\")"; + } + } + String valueSchema = generateSchema(valueType, typeUtils, elementUtils); + return "Map.of(\"type\", \"object\", \"additionalProperties\", " + valueSchema + ")"; + } + return "Map.of(\"type\", \"object\")"; + } + + // Enum types + if (typeElement.getKind() == ElementKind.ENUM) { + List constants = typeElement.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.ENUM_CONSTANT) + .map(e -> "\"" + e.getSimpleName().toString() + "\"").collect(Collectors.toList()); + return "Map.of(\"type\", \"string\", \"enum\", List.of(" + String.join(", ", constants) + "))"; + } + + // Record types + if (typeElement.getKind() == ElementKind.RECORD) { + return generateRecordSchema(typeElement, typeUtils, elementUtils); + } + + // POJO / class types — treat as object with fields + if (typeElement.getKind() == ElementKind.CLASS) { + return generateClassSchema(typeElement, typeUtils, elementUtils); + } + + // Sealed interfaces — oneOf via permitted subclasses + if (typeElement.getKind() == ElementKind.INTERFACE) { + return generateSealedSchema(typeElement, typeUtils, elementUtils); + } + + return "Map.of()"; + } + + private String generateRecordSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { + List propertyEntries = new ArrayList<>(); + List requiredNames = new ArrayList<>(); + + for (Element enclosed : typeElement.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.RECORD_COMPONENT) { + RecordComponentElement component = (RecordComponentElement) enclosed; + String name = component.getSimpleName().toString(); + TypeMirror componentType = component.asType(); + + boolean isOptional = isOptionalType(componentType, typeUtils, elementUtils); + String schema; + if (isOptional) { + schema = generateSchema(unwrapOptional(componentType, typeUtils, elementUtils), typeUtils, + elementUtils); + } else { + schema = generateSchema(componentType, typeUtils, elementUtils); + requiredNames.add("\"" + name + "\""); + } + + propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")"); + } + } + + String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; + String required = "List.of(" + String.join(", ", requiredNames) + ")"; + + return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; + } + + private String generateClassSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { + List propertyEntries = new ArrayList<>(); + List requiredNames = new ArrayList<>(); + + for (Element enclosed : typeElement.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.FIELD) { + VariableElement field = (VariableElement) enclosed; + // Skip static fields + if (field.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) { + continue; + } + String name = field.getSimpleName().toString(); + TypeMirror fieldType = field.asType(); + + boolean isOptional = isOptionalType(fieldType, typeUtils, elementUtils); + String schema; + if (isOptional) { + schema = generateSchema(unwrapOptional(fieldType, typeUtils, elementUtils), typeUtils, + elementUtils); + } else { + schema = generateSchema(fieldType, typeUtils, elementUtils); + requiredNames.add("\"" + name + "\""); + } + + propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")"); + } + } + + if (propertyEntries.isEmpty()) { + return "Map.of(\"type\", \"object\")"; + } + + String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; + String required = "List.of(" + String.join(", ", requiredNames) + ")"; + + return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; + } + + private String generateSealedSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { + List permittedSubclasses = typeElement.getPermittedSubclasses(); + if (permittedSubclasses != null && !permittedSubclasses.isEmpty()) { + List schemas = permittedSubclasses.stream().map(sub -> generateSchema(sub, typeUtils, elementUtils)) + .collect(Collectors.toList()); + return "Map.of(\"oneOf\", List.of(" + String.join(", ", schemas) + "))"; + } + return "Map.of(\"type\", \"object\")"; + } + + private boolean isOptionalType(TypeMirror type, Types typeUtils, Elements elementUtils) { + if (type.getKind() != TypeKind.DECLARED) { + return false; + } + DeclaredType declaredType = (DeclaredType) type; + TypeElement element = (TypeElement) declaredType.asElement(); + String name = element.getQualifiedName().toString(); + return "java.util.Optional".equals(name) || "java.util.OptionalInt".equals(name) + || "java.util.OptionalDouble".equals(name) || "java.util.OptionalLong".equals(name); + } + + private TypeMirror unwrapOptional(TypeMirror type, Types typeUtils, Elements elementUtils) { + if (type.getKind() != TypeKind.DECLARED) { + return type; + } + DeclaredType declaredType = (DeclaredType) type; + TypeElement element = (TypeElement) declaredType.asElement(); + String name = element.getQualifiedName().toString(); + + if ("java.util.Optional".equals(name)) { + List typeArgs = declaredType.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return typeArgs.get(0); + } + } + if ("java.util.OptionalInt".equals(name)) { + return typeUtils.getPrimitiveType(TypeKind.INT); + } + if ("java.util.OptionalDouble".equals(name)) { + return typeUtils.getPrimitiveType(TypeKind.DOUBLE); + } + if ("java.util.OptionalLong".equals(name)) { + return typeUtils.getPrimitiveType(TypeKind.LONG); + } + return type; + } + + private boolean isCollectionType(String qualifiedName) { + return "java.util.List".equals(qualifiedName) || "java.util.Collection".equals(qualifiedName) + || "java.util.Set".equals(qualifiedName); + } + + private boolean isMapType(String qualifiedName) { + return "java.util.Map".equals(qualifiedName); + } +} diff --git a/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java b/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java new file mode 100644 index 000000000..8e024ab9f --- /dev/null +++ b/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java @@ -0,0 +1,700 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link SchemaGenerator} using the compilation-testing approach. A + * test annotation processor exercises SchemaGenerator during compilation of + * small source snippets. + */ +public class SchemaGeneratorTest { + + /** + * In-memory Java source file for compilation testing. + */ + private static class InMemorySource extends SimpleJavaFileObject { + + private final String code; + + InMemorySource(String className, String code) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { + return code; + } + } + + /** + * Test processor that captures schema generation results. + */ + @SupportedAnnotationTypes("*") + @SupportedSourceVersion(SourceVersion.RELEASE_17) + public static class SchemaCapturingProcessor extends AbstractProcessor { + + static final List capturedSchemas = new ArrayList<>(); + static final List capturedParameterSchemas = new ArrayList<>(); + + private Types typeUtils; + private Elements elementUtils; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.typeUtils = processingEnv.getTypeUtils(); + this.elementUtils = processingEnv.getElementUtils(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (roundEnv.processingOver()) { + return false; + } + + SchemaGenerator generator = new SchemaGenerator(); + + for (Element rootElement : roundEnv.getRootElements()) { + if (rootElement.getKind() == ElementKind.CLASS || rootElement.getKind() == ElementKind.RECORD + || rootElement.getKind() == ElementKind.INTERFACE + || rootElement.getKind() == ElementKind.ENUM) { + // Find methods named "schemaTarget" to capture schemas for their return type + for (Element enclosed : rootElement.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + String methodName = method.getSimpleName().toString(); + if (methodName.startsWith("schemaTarget")) { + TypeMirror returnType = method.getReturnType(); + String schema = generator.generateSchemaSource(returnType, typeUtils, elementUtils); + capturedSchemas.add(methodName + "=" + schema); + } + if ("parametersTarget".equals(methodName)) { + List params = method.getParameters(); + String schema = generator.generateParametersSchemaSource(params, typeUtils, + elementUtils); + capturedParameterSchemas.add(schema); + } + } + } + + // For record/enum types, generate schema for the type itself + TypeElement typeElement = (TypeElement) rootElement; + String typeName = typeElement.getSimpleName().toString(); + if (typeName.startsWith("TestRecord") || typeName.startsWith("TestEnum") + || typeName.startsWith("TestSealed")) { + String schema = generator.generateSchemaSource(typeElement.asType(), typeUtils, elementUtils); + capturedSchemas.add(typeName + "=" + schema); + } + } + } + + return false; + } + } + + private static final Path CLASS_OUTPUT_DIR = Path.of("target", "test-schema-classes"); + + /** + * Creates a StandardJavaFileManager that writes compiled .class files to + * target/test-schema-classes/ instead of the working directory. + */ + private StandardJavaFileManager createFileManager(JavaCompiler compiler, + DiagnosticCollector diagnostics) throws IOException { + Files.createDirectories(CLASS_OUTPUT_DIR); + StandardJavaFileManager fm = compiler.getStandardFileManager(diagnostics, null, null); + fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(CLASS_OUTPUT_DIR.toFile())); + return fm; + } + + private List compileAndCapture(String... sources) { + return compileAndCapture(Arrays.asList(sources)); + } + + private List compileAndCapture(List sourceTexts) { + SchemaCapturingProcessor.capturedSchemas.clear(); + SchemaCapturingProcessor.capturedParameterSchemas.clear(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "System Java compiler not available"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + List compilationUnits = new ArrayList<>(); + for (String sourceText : sourceTexts) { + // Extract class name from source + String className = extractClassName(sourceText); + compilationUnits.add(new InMemorySource(className, sourceText)); + } + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + // Compile with the processor on classpath + JavaCompiler.CompilationTask task = compiler.getTask(null, // writer + fm, // file manager + diagnostics, // diagnostics + List.of("--add-modules", "ALL-MODULE-PATH"), // options + null, // annotation classes + compilationUnits); + + task.setProcessors(List.of(new SchemaCapturingProcessor())); + boolean success = task.call(); + + if (!success) { + // Try without module options for simpler environments + diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fm2 = createFileManager(compiler, diagnostics)) { + task = compiler.getTask(null, fm2, diagnostics, null, null, compilationUnits); + task.setProcessors(List.of(new SchemaCapturingProcessor())); + success = task.call(); + } + } + + assertTrue(success, "Compilation failed: " + diagnostics.getDiagnostics()); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + return new ArrayList<>(SchemaCapturingProcessor.capturedSchemas); + } + + private List compileAndCaptureParams(String source) { + SchemaCapturingProcessor.capturedSchemas.clear(); + SchemaCapturingProcessor.capturedParameterSchemas.clear(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "System Java compiler not available"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + String className = extractClassName(source); + List compilationUnits = List.of(new InMemorySource(className, source)); + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, compilationUnits); + task.setProcessors(List.of(new SchemaCapturingProcessor())); + boolean success = task.call(); + + assertTrue(success, "Compilation failed: " + diagnostics.getDiagnostics()); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + return new ArrayList<>(SchemaCapturingProcessor.capturedParameterSchemas); + } + + private String extractClassName(String source) { + // Simple extraction: find "class X", "record X", "enum X", or "interface X" + for (String keyword : new String[]{"class ", "record ", "enum ", "interface "}) { + int idx = source.indexOf(keyword); + if (idx >= 0) { + int start = idx + keyword.length(); + int end = start; + while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) { + end++; + } + return source.substring(start, end); + } + } + return "Unknown"; + } + + // --- Type mapping tests --- + + @Test + void stringType() { + String source = """ + public class TestStringHolder { + public String schemaTargetString() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetString", "Map.of(\"type\", \"string\")"); + } + + @Test + void intPrimitiveType() { + String source = """ + public class TestIntHolder { + public int schemaTargetInt() { return 0; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetInt", "Map.of(\"type\", \"integer\")"); + } + + @Test + void integerBoxedType() { + String source = """ + public class TestIntegerHolder { + public Integer schemaTargetInteger() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetInteger", "Map.of(\"type\", \"integer\")"); + } + + @Test + void longType() { + String source = """ + public class TestLongHolder { + public long schemaTargetLong() { return 0L; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetLong", "Map.of(\"type\", \"integer\")"); + } + + @Test + void doubleType() { + String source = """ + public class TestDoubleHolder { + public double schemaTargetDouble() { return 0.0; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetDouble", "Map.of(\"type\", \"number\")"); + } + + @Test + void floatType() { + String source = """ + public class TestFloatHolder { + public float schemaTargetFloat() { return 0.0f; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetFloat", "Map.of(\"type\", \"number\")"); + } + + @Test + void booleanPrimitiveType() { + String source = """ + public class TestBooleanHolder { + public boolean schemaTargetBoolean() { return false; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetBoolean", "Map.of(\"type\", \"boolean\")"); + } + + @Test + void booleanBoxedType() { + String source = """ + public class TestBooleanBoxedHolder { + public Boolean schemaTargetBooleanBoxed() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetBooleanBoxed", "Map.of(\"type\", \"boolean\")"); + } + + @Test + void byteBoxedType() { + String source = """ + public class TestByteHolder { + public Byte schemaTargetByte() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetByte", "Map.of(\"type\", \"integer\")"); + } + + @Test + void shortBoxedType() { + String source = """ + public class TestShortHolder { + public Short schemaTargetShort() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetShort", "Map.of(\"type\", \"integer\")"); + } + + @Test + void characterBoxedType() { + String source = """ + public class TestCharHolder { + public Character schemaTargetChar() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetChar", "Map.of(\"type\", \"string\")"); + } + + @Test + void stringArrayType() { + String source = """ + public class TestArrayHolder { + public String[] schemaTargetArray() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetArray", + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\"))"); + } + + @Test + void enumType() { + String source = """ + public enum TestEnumColor { RED, GREEN, BLUE } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "TestEnumColor", + "Map.of(\"type\", \"string\", \"enum\", List.of(\"RED\", \"GREEN\", \"BLUE\"))"); + } + + @Test + void listOfStringType() { + String source = """ + import java.util.List; + public class TestListHolder { + public List schemaTargetList() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetList", + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\"))"); + } + + @Test + void mapStringStringType() { + String source = """ + import java.util.Map; + public class TestMapHolder { + public Map schemaTargetMap() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMap", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"string\"))"); + } + + @Test + void mapStringObjectType() { + String source = """ + import java.util.Map; + public class TestMapObjectHolder { + public Map schemaTargetMapObject() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapObject", "Map.of(\"type\", \"object\")"); + } + + @Test + void mapStringBooleanType() { + String source = """ + import java.util.Map; + public class TestMapBoolHolder { + public Map schemaTargetMapBool() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapBool", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"boolean\"))"); + } + + @Test + void mapStringLongType() { + String source = """ + import java.util.Map; + public class TestMapLongHolder { + public Map schemaTargetMapLong() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapLong", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"integer\"))"); + } + + @Test + void optionalStringType() { + String source = """ + import java.util.Optional; + public class TestOptionalHolder { + public Optional schemaTargetOptional() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptional", "Map.of(\"type\", \"string\")"); + } + + @Test + void optionalIntType() { + String source = """ + import java.util.OptionalInt; + public class TestOptionalIntHolder { + public OptionalInt schemaTargetOptionalInt() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalInt", "Map.of(\"type\", \"integer\")"); + } + + @Test + void optionalLongType() { + String source = """ + import java.util.OptionalLong; + public class TestOptionalLongHolder { + public OptionalLong schemaTargetOptionalLong() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalLong", "Map.of(\"type\", \"integer\")"); + } + + @Test + void optionalDoubleType() { + String source = """ + import java.util.OptionalDouble; + public class TestOptionalDoubleHolder { + public OptionalDouble schemaTargetOptionalDouble() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalDouble", "Map.of(\"type\", \"number\")"); + } + + @Test + void uuidType() { + String source = """ + import java.util.UUID; + public class TestUuidHolder { + public UUID schemaTargetUuid() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetUuid", "Map.of(\"type\", \"string\", \"format\", \"uuid\")"); + } + + @Test + void offsetDateTimeType() { + String source = """ + import java.time.OffsetDateTime; + public class TestDateTimeHolder { + public OffsetDateTime schemaTargetDateTime() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetDateTime", + "Map.of(\"type\", \"string\", \"format\", \"date-time\")"); + } + + @Test + void recordType() { + String source = """ + public record TestRecordPerson(String name, int age, boolean active) {} + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"name\", Map.of(\"type\", \"string\")), " + + "Map.entry(\"age\", Map.of(\"type\", \"integer\")), " + + "Map.entry(\"active\", Map.of(\"type\", \"boolean\"))), " + + "\"required\", List.of(\"name\", \"age\", \"active\"))"; + assertContainsSchema(schemas, "TestRecordPerson", expected); + } + + @Test + void recordWithOptionalField() { + String source = """ + import java.util.Optional; + public record TestRecordWithOptional(String name, Optional nickname) {} + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"name\", Map.of(\"type\", \"string\")), " + + "Map.entry(\"nickname\", Map.of(\"type\", \"string\"))), " + "\"required\", List.of(\"name\"))"; + assertContainsSchema(schemas, "TestRecordWithOptional", expected); + } + + @Test + void recordWithMoreThanTenFields() { + String source = """ + public record TestRecordLarge( + String f1, String f2, String f3, String f4, String f5, + String f6, String f7, String f8, String f9, String f10, + String f11) {} + """; + List schemas = compileAndCapture(source); + // Verify the schema contains all 11 fields and uses Map.ofEntries + String schema = schemas.stream().filter(s -> s.startsWith("TestRecordLarge=")).findFirst().orElse(""); + assertFalse(schema.isEmpty(), "Expected schema for TestRecordLarge"); + assertTrue(schema.contains("Map.ofEntries("), "Should use Map.ofEntries for >10 fields: " + schema); + assertTrue(schema.contains("Map.entry(\"f1\""), "Should have f1: " + schema); + assertTrue(schema.contains("Map.entry(\"f11\""), "Should have f11: " + schema); + // Verify the generated source expression is compilable by re-compiling it + String schemaExpr = schema.substring(schema.indexOf('=') + 1); + String validationSource = "import java.util.Map;\nimport java.util.List;\n" + + "public class LargeRecordValidation {\n" + " @SuppressWarnings(\"unchecked\")\n" + + " public Object schema() { return " + schemaExpr + "; }\n}\n"; + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List units = List.of(new InMemorySource("LargeRecordValidation", validationSource)); + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, units); + boolean success = task.call(); + assertTrue(success, "Generated schema for >10-field record does not compile: " + + diagnostics.getDiagnostics() + "\nSource:\n" + validationSource); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + } + + @Test + void parametersSchema() { + String source = """ + public class TestParamsHolder { + public void parametersTarget(String query, int limit, boolean verbose) {} + } + """; + List paramSchemas = compileAndCaptureParams(source); + assertFalse(paramSchemas.isEmpty(), "Expected parameter schemas"); + String schema = paramSchemas.get(0); + assertTrue(schema.contains("\"type\", \"object\""), "Should be object type: " + schema); + assertTrue(schema.contains("Map.entry(\"query\", Map.of(\"type\", \"string\"))"), + "Should have query property: " + schema); + assertTrue(schema.contains("Map.entry(\"limit\", Map.of(\"type\", \"integer\"))"), + "Should have limit property: " + schema); + assertTrue(schema.contains("Map.entry(\"verbose\", Map.of(\"type\", \"boolean\"))"), + "Should have verbose property: " + schema); + assertTrue(schema.contains("\"required\", List.of("), "Should have required list: " + schema); + } + + @Test + void generatedSourceIsValidJava() { + // Verify that generated schema source code compiles when embedded in a method + // body + String source = """ + import java.util.List; + import java.util.Map; + import java.util.Optional; + public class TestValidJavaHolder { + public String schemaTargetStr() { return null; } + public List schemaTargetListStr() { return null; } + public Map schemaTargetMapStr() { return null; } + public Optional schemaTargetOpt() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertFalse(schemas.isEmpty()); + + // Build a Java source that uses the generated schema expressions + StringBuilder validationSource = new StringBuilder(); + validationSource.append("import java.util.Map;\n"); + validationSource.append("import java.util.List;\n"); + validationSource.append("public class SchemaValidation {\n"); + validationSource.append(" @SuppressWarnings(\"unchecked\")\n"); + validationSource.append(" public void validate() {\n"); + for (int i = 0; i < schemas.size(); i++) { + String schema = schemas.get(i); + String schemaExpr = schema.substring(schema.indexOf('=') + 1); + validationSource.append(" Object s" + i + " = " + schemaExpr + ";\n"); + } + validationSource.append(" }\n"); + validationSource.append("}\n"); + + // Compile the validation source to verify syntactic validity + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List compilationUnits = List + .of(new InMemorySource("SchemaValidation", validationSource.toString())); + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, compilationUnits); + boolean success = task.call(); + + assertTrue(success, "Generated schema source code is not valid Java: " + diagnostics.getDiagnostics() + + "\nSource:\n" + validationSource); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + } + + @Test + void nestedMapListType() { + String source = """ + import java.util.List; + import java.util.Map; + public class TestNestedHolder { + public Map> schemaTargetNestedMap() { return null; } + } + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"additionalProperties\", " + + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\")))"; + assertContainsSchema(schemas, "schemaTargetNestedMap", expected); + } + + @Test + void objectType() { + String source = """ + public class TestObjectHolder { + public Object schemaTargetObject() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetObject", "Map.of()"); + } + + @Test + void sealedInterfaceType() { + String sealedInterface = """ + public sealed interface TestSealedShape permits TestSealedCircle, TestSealedRect {} + """; + String circle = """ + public record TestSealedCircle(double radius) implements TestSealedShape {} + """; + String rect = """ + public record TestSealedRect(double width, double height) implements TestSealedShape {} + """; + List schemas = compileAndCapture(sealedInterface, circle, rect); + String expected = "Map.of(\"oneOf\", List.of(" + "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"radius\", Map.of(\"type\", \"number\"))), " + + "\"required\", List.of(\"radius\")), " + "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"width\", Map.of(\"type\", \"number\")), " + + "Map.entry(\"height\", Map.of(\"type\", \"number\"))), " + + "\"required\", List.of(\"width\", \"height\"))))"; + assertContainsSchema(schemas, "TestSealedShape", expected); + } + + private void assertContainsSchema(List schemas, String methodName, String expectedSchema) { + String expected = methodName + "=" + expectedSchema; + assertTrue(schemas.stream().anyMatch(s -> s.equals(expected)), + "Expected schema '" + expected + "' not found in: " + schemas); + } +} From 403ac7de4cf9a7df9926062e58fb9afa6c79481c Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 16:14:06 -0400 Subject: [PATCH 17/29] WIP 4.3 --- .../20260623-prompts.md | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 1682-java-tool-ergonomics-prompts-remove-before-merge/20260623-prompts.md diff --git a/1682-java-tool-ergonomics-prompts-remove-before-merge/20260623-prompts.md b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260623-prompts.md new file mode 100644 index 000000000..f22907566 --- /dev/null +++ b/1682-java-tool-ergonomics-prompts-remove-before-merge/20260623-prompts.md @@ -0,0 +1,689 @@ +### 13:14 Prompt + +Consider this comment: + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461548421 + +I see an option C: generate the .class files into a sub-directory of `target`. + +Why was that not proposed? + +### 13:18 Response + +Updated todo list + + + +Fetched https://github.com/github/copilot-sdk/pull/1766#discussion_r3461548421 + +Now I understand the context. The review comment on PR #1766 proposes two options for handling `.class` files accidentally written by `SchemaGeneratorTest`: + +- **Option A**: RAM-only `ForwardingJavaFileManager` that discards class output +- **Option B**: .gitignore + `git rm --cached` + +Your **Option C** — configuring the `StandardJavaFileManager` to write `.class` output into `target/` (e.g., `target/test-schema-classes/`) — is a perfectly valid middle ground. You'd just call: + +```java +stdFm.setLocation(StandardLocation.CLASS_OUTPUT, + List.of(Path.of("target", "test-schema-classes").toFile())); +``` + +It wasn't proposed because the comment was generated by the automated **SDK Consistency Review Agent** (a bot), which likely only considered the two canonical patterns: "don't write at all" (RAM-only) vs. "ignore what gets written" (gitignore). The bot didn't reason about the Maven-idiomatic option of directing output to `target/`, which: + +1. Is already gitignored by Maven convention +2. Gets cleaned by `mvn clean` +3. Avoids the complexity of a custom `ForwardingJavaFileManager` +4. Doesn't leave stale files permanently on disk like Option B could + +Option C is arguably the best fit here — simpler than A, cleaner than B, and idiomatic for a Maven project. Would you like me to implement it in the test? + +### 13:18 Prompt + + +Yes please. + +### 13:36 Response + + +Updated todo list + +Searched for files matching `**/SchemaGeneratorTest.java`, 1 match + +Read skill [java-coding-skill](file:///Users/edburns/workareas/copilot-sdk-00/.github/skills/java-coding-skill/SKILL.md?vscodeLinkType%3Dskill#1-1), lines 1 to 100 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#1-1), lines 1 to 200 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#200-200), lines 200 to 400 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#400-400), lines 400 to 544 + +Now I'll implement Option C. I need to modify `compileAndCapture`, `compileAndCaptureParams`, and the `generatedSourceIsValidJava` test to use a `StandardJavaFileManager` with `CLASS_OUTPUT` set to `target/test-schema-classes/`. + +Replacing 83 lines with 116 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` +Now let me verify the file compiles. First, I'll set up the environment and run the test: + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && LOG="$(date +%Y%m%d-%H%M)-job-logs.txt" && echo "$LOG" && mvn test -Dtest=SchemaGeneratorTest -pl . > "$LOG" 2>&1 & tail -f "$LOG" + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && LOG="20260623-1321-job-logs.txt" && mvn test -Dtest=SchemaGeneratorTest -pl . > "$LOG" 2>&1 & sleep 1 && tail -f "$LOG" + +The sandbox seems to be interfering with the `cd` + background pattern. Let me try directly: + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME=${HOME}/Downloads/apache-maven-3.9.8 && export PATH=${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH} && mvn test -Dtest=SchemaGeneratorTest -pl . + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && mvn test -Dtest=SchemaGeneratorTest -pl . + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && /Users/edburns/Downloads/apache-maven-3.9.8/bin/mvn test -Dtest=SchemaGeneratorTest -pl . + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +Ran terminal command: chmod +x mvnw && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +Read [](file:///Users/edburns/Library/Application%20Support/Code%20-%20Insiders/User/workspaceStorage/4e381c054ce5ecfaa52d39ffc116fa91/GitHub.copilot-chat/chat-session-resources/1c49da46-1ce8-463b-a582-85d83c4ac357/toolu_bdrk_01Py9juEm7rMeTPTo1RfMix4__vscode-1782234675778/content.txt#1-1), lines 1 to 100 + +Read [](file:///Users/edburns/Library/Application%20Support/Code%20-%20Insiders/User/workspaceStorage/4e381c054ce5ecfaa52d39ffc116fa91/GitHub.copilot-chat/chat-session-resources/1c49da46-1ce8-463b-a582-85d83c4ac357/toolu_bdrk_01Py9juEm7rMeTPTo1RfMix4__vscode-1782234675778/content.txt#100-100), lines 100 to 158 + +All 24 tests pass. Let me also verify that `.class` files now go to `target/test-schema-classes/` and not the java root: + +Ran terminal command: ls /Users/edburns/workareas/copilot-sdk-00/java/target/test-schema-classes/ && echo "---" && ls /Users/edburns/workareas/copilot-sdk-00/java/*.class 2>&1 + +Option C is working correctly: + +- **`target/test-schema-classes/`** — contains all 25 compiled `.class` files (cleaned by `mvn clean`) +- **java root** — no `.class` files present + +All 24 tests pass. The implementation adds a `createFileManager()` helper that sets `StandardLocation.CLASS_OUTPUT` to `target/test-schema-classes/`, which is used by all three compilation sites in the test. No custom `ForwardingJavaFileManager` complexity, and `mvn clean` handles cleanup automatically. + +Created 3 todos + +Made changes. + +### 13:41 Prompt + +Analyze `java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java` and explain in detail what this test does and how it asserts the correctness of the code under test. + +### 13:43 Response + + +Updated todo list + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#1-1), lines 1 to 210 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#210-210), lines 210 to 550 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#550-550), lines 550 to 576 + +Now let me also look at the code under test: + +Searched for files matching `**/SchemaGenerator.java`, 1 match + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#1-1), lines 1 to 250 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#250-250), lines 250 to 378 + +Here's the detailed analysis: + +## What `SchemaGeneratorTest` tests + +It verifies that `SchemaGenerator` — a **compile-time** utility — correctly maps `javax.lang.model` types (the type system available inside annotation processors) to JSON Schema expressed as Java source code literals (`Map.of(...)` / `List.of(...)` strings). + +## Testing mechanism: compilation-testing + +The test can't just call `SchemaGenerator` directly because its API requires `TypeMirror`, `Types`, and `Elements` — objects that only exist during `javac` annotation processing. So it uses a **compilation-testing** pattern: + +1. **`InMemorySource`** — wraps a Java source `String` as a `SimpleJavaFileObject`, so `javac` can compile it without writing source files to disk. + +2. **`SchemaCapturingProcessor`** — a custom annotation processor (`@SupportedAnnotationTypes("*")`) that runs during compilation. Inside `process()`, it: + - Scans each root element (class/record/enum/interface) for methods whose name starts with `schemaTarget` — then calls `generator.generateSchemaSource()` on that method's **return type**, recording `"methodName=schemaOutput"` in a static list. + - For methods named `parametersTarget`, calls `generator.generateParametersSchemaSource()` on the method's parameter list. + - For root types named `TestRecord*`, `TestEnum*`, or `TestSealed*`, generates schema for the type itself (records, enums, sealed interfaces). + +3. **`compileAndCapture()`** / **`compileAndCaptureParams()`** — helper methods that: + - Create an in-memory compilation unit from the source string + - Invoke `JavaCompiler.getTask()` with a `StandardJavaFileManager` configured to write `.class` output to `target/test-schema-classes/` + - Register `SchemaCapturingProcessor` + - Call `task.call()` to trigger compilation → annotation processing → schema capture + - Return the captured schema strings + +## What the 24 tests assert + +Each test provides a tiny Java source snippet with a strategically typed method, compiles it, and asserts that `SchemaGenerator` produces the exact expected `Map.of(...)` string: + +| Category | Tests | Example assertion | +|---|---|---| +| **Primitives & boxed** | `int`, `long`, `double`, `float`, `boolean` + `Integer`, `Boolean` | `int` → `Map.of("type", "integer")` | +| **String** | `stringType` | → `Map.of("type", "string")` | +| **Arrays** | `stringArrayType` | `String[]` → `Map.of("type", "array", "items", Map.of("type", "string"))` | +| **Collections** | `listOfStringType` | `List` → same as array of string | +| **Maps** | `mapStringString`, `mapStringObject`, `mapStringBoolean`, `mapStringLong` | `Map` → `Map.of("type", "object", "additionalProperties", ...)` with typed values, or just `"object"` for `Object` values | +| **Optional** | `optionalStringType` | `Optional` → unwraps to `Map.of("type", "string")` | +| **Date/UUID** | `uuidType`, `offsetDateTimeType` | → `"string"` with `"format"` (`"uuid"` / `"date-time"`) | +| **Enums** | `enumType` | `enum {RED, GREEN, BLUE}` → `Map.of("type", "string", "enum", List.of("RED", "GREEN", "BLUE"))` | +| **Records** | `recordType`, `recordWithOptionalField` | Record components → `"object"` with `"properties"` and `"required"` (optional fields excluded from required) | +| **Nested types** | `nestedMapListType` | `Map>` → nested `additionalProperties` with array schema | +| **Object fallback** | `objectType` | `Object` → `Map.of()` (empty = any) | +| **Parameters** | `parametersSchema` | Multi-param method → full `"type": "object"` with properties and required list | +| **Source validity** | `generatedSourceIsValidJava` | Takes generated schema expressions, embeds them in a new Java class, and **re-compiles** to prove they are syntactically valid Java | + +## How correctness is asserted + +- **Exact string matching** (`assertContainsSchema`): most tests assert the captured schema string equals `"methodName=" + expectedMapLiteral` exactly. +- **Substring matching** (`parametersSchema`): the parameters test uses `assertTrue(schema.contains(...))` for each property and the required list, since parameter ordering may vary. +- **Compilation round-trip** (`generatedSourceIsValidJava`): the strongest assertion — it proves the generated `Map.of(...)` expressions are not just correct strings but actually **compilable Java source code**, by embedding them as variable initializers in a new class and compiling that class. + +### 13:44 Prompt + +Does the test fail if the compilation fails? + +### 13:44 Response + +Updated todo list + +Yes. Every compilation path asserts success: + +- **`compileAndCapture()`** (line ~196): `assertTrue(success, "Compilation failed: " + diagnostics.getDiagnostics())` — fails the test with the compiler diagnostics if compilation doesn't succeed. (It also tries a fallback without `--add-modules` first, but if that also fails, the assertion fires.) + +- **`compileAndCaptureParams()`** (line ~213): same `assertTrue(success, ...)` pattern. + +- **`generatedSourceIsValidJava()`** (line ~505): `assertTrue(success, "Generated schema source code is not valid Java: " + diagnostics.getDiagnostics() + "\nSource:\n" + validationSource)` — additionally includes the generated source in the failure message for debugging. + +- **`createFileManager()`** (line ~142): if the file manager can't be created (e.g., `CLASS_OUTPUT_DIR` can't be created), the `IOException` is caught and `fail(...)` is called. + +So a compilation failure at any stage — the initial source compilation, the module-option fallback retry, or the round-trip re-compilation of generated schema expressions — will fail the test with a diagnostic message. + +### 13:44 Prompt + +Would it make sense to add Jackson serialize/deserialize of the types to the test? + +### 13:46 Response + +Updated todo list + +No, it wouldn't add meaningful value here. A few reasons: + +1. **Wrong abstraction layer.** `SchemaGenerator` produces **Java source code strings** (`Map.of(...)` literals), not runtime objects. To test Jackson serialization you'd need to compile the source → execute it → get a `Map` → serialize with Jackson → deserialize → assert. At that point you're testing `Map.of()` and Jackson, not `SchemaGenerator`. + +2. **The existing tests already cover structure correctness.** The exact string equality assertions verify the precise schema structure (keys, nesting, required lists). The `generatedSourceIsValidJava` test proves the expressions are compilable. Together, these guarantee the output is correct. + +3. **`SchemaGenerator` has no Jackson dependency.** It uses only `javax.lang.model` APIs. Adding Jackson to its test would introduce a coupling that doesn't exist in production code. + +4. **JSON Schema wire-format validity belongs at the integration level.** When the annotation processor output is consumed by the Copilot CLI over JSON-RPC, that's where schema correctness at the JSON level matters — and that's what the E2E tests (with the replay proxy) cover. + +Where Jackson testing *would* make sense is in the **annotation processor** that consumes `SchemaGenerator` — verifying that the generated `@CopilotTool` adapter class produces a tool definition that serializes to valid JSON matching the Copilot protocol. That's a different test, at a higher layer. + +### 13:46 Prompt + +Find the right place in the description of https://github.com/github/copilot-sdk/issues/1760 to insert guidance so the agent assigned that issue adds the relevant jackson serialize/deserialize tests. + +### 13:49 Response + +Updated todo list + +You're right — your instructions say to use `gh` CLI for GitHub interactions. Let me do that. + +Ran terminal command: gh issue view 1760 --json body --jq '.body' | head -20 + +Ran terminal command: gh issue view 1760 --json body --jq '.body' | head -20 + +Ran terminal command: gh issue view 1760 --json body --jq '.body' > /tmp/issue-1760-body.md && wc -l /tmp/issue-1760-body.md + +Ran terminal command: gh issue view 1760 --json body --jq '.body' > /tmp/issue-1760-body.md && wc -l /tmp/issue-1760-body.md + +Read [](file:///tmp/issue-1760-body.md) + +Now I can see the exact text. The new item goes after item 5 (argument coercion tests) at line 127. Let me prepare the edited body and update the issue: + +Replacing 9 lines with 11 lines in [](file:///tmp/issue-1760-body.md) + + +``` + +``` + + +Ran terminal command: gh issue edit 1760 --body-file /tmp/issue-1760-body.md + +Ran terminal command: gh issue edit 1760 --body-file /tmp/issue-1760-body.md + +Done. Issue #1760 now has a new **item 6** in the gating tests: + +> **6. Jackson round-trip test:** Load the generated `$$CopilotToolMeta` class, call `definitions(instance)` to obtain the `List`, then for each definition: serialize its `inputSchema` to JSON via `RpcMapper.INSTANCE.writeValueAsString(...)`, verify the JSON contains the expected JSON Schema keys, and deserialize back via `readValue(...)` to a `Map` and assert equality with the original. + +The previous items 6–9 were renumbered to 7–10. + +Created 2 todos + +Made changes. + +### 13:52 Prompt + +Now we embark on resolving the review comments. + +I will have you do these locally. For each one, we'll do the change, and ensure the SchemaGenerator test still passes. + +Let's start with this one: + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777483 + +Use the `gh` command to interact with GitHub. + +Make the changes and commit, but do not push. + +### 13:54 Prompt + +Why do the commands you keep invoking get detected as "outside of the sandbox". This isn't what usually happens. + +### 13:55 Response + +Updated todo list + +It's the **git worktree** setup. Your .git file contains: + +``` +gitdir: /Users/edburns/workareas/copilot-sdk/.git/worktrees/copilot-sdk-00 +``` + +This points outside the workspace (`copilot-sdk-00`) to the main repo's .git directory at `copilot-sdk/.git/`. The sandbox allows normal `.git/` directories within the workspace, but a worktree's external reference gets blocked. + +For Maven, it should actually work sandboxed now — the `./mvnw` wrapper is in-workspace, and the sandbox allows read access to JDK and `~/.m2` as common developer tool paths. The initial `permission denied` was just a missing `chmod +x`, not a sandbox issue. + +Let me stop over-requesting unsandboxed execution. Git commands genuinely need it for this worktree, but Maven and `gh` shouldn't. + + +### 13:58 Response + +Updated todo list + +Ran terminal command: gh api repos/github/copilot-sdk/pulls/1766/reviews --jq '.[] | .id' + +Ran terminal command: gh api repos/github/copilot-sdk/pulls/1766/reviews --jq '.[] | .id' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777483) | {id, path, line, original_line, diff_hunk, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777483) | {id, path, line, original_line, diff_hunk, body}' + +The review comment says `Map.of()` only supports up to 10 key-value pairs, so properties maps should use `Map.ofEntries(Map.entry(...), ...)` instead. Let me identify all affected locations in SchemaGenerator.java: + +Searched for text `propertyEntries.add` (`**/SchemaGenerator.java`), 3 results + +Searched for text `String properties = ` (`**/SchemaGenerator.java`), 3 results + +Three locations — `generateParametersSchemaSource()`, `generateRecordSchema()`, and `generateClassSchema()`. I need to change all three to use `Map.ofEntries(Map.entry(...), ...)`. Let me read the surrounding context for each: + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#82-82), lines 82 to 102 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#265-265), lines 265 to 286 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#299-299), lines 299 to 330 + +Now I'll apply the changes — switch `propertyEntries` to use `Map.entry(k, v)` format and `Map.ofEntries(...)` for all three locations: + +Replacing 31 lines with 31 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java) + + +``` + +``` +Now I need to update the test expectations. The tests assert exact string matches with `Map.of(...)` for properties — these need to change to `Map.ofEntries(Map.entry(...), ...)`: + +Searched for text `Map.of(\"name\"` (`**/SchemaGeneratorTest.java`), 2 results + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#448-448), lines 448 to 500 + +Now update the test expectations — records and the `parametersSchema` test need to match the new `Map.ofEntries(Map.entry(...), ...)` format: + +Replacing 15 lines with 18 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` +Now I also need to update the `generatedSourceIsValidJava` test — it re-compiles the generated schema expressions, which now use `Map.ofEntries` and `Map.entry`, so the validation source needs to import `Map.entry`: + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#497-497), lines 497 to 530 + +The `generatedSourceIsValidJava` test doesn't use records/POJOs (only `String`, `List`, `Map`, `Optional`), so it won't produce `Map.ofEntries`. No change needed there. + +Now let me run the tests: + +Ran terminal command: chmod +x mvnw && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +All 24 tests pass. Now run spotless and commit: + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw spotless:apply -pl . + +Spotless reformatted the test file. Now let me check the current branch and commit: + +Ran terminal command: git branch --show-current && git diff --stat + +Ran terminal command: cat .git + +Ran terminal command: ls /Users/edburns/workareas/copilot-sdk/.git/worktrees/ + +The sandbox blocks access to the main worktree's .git directory. I need unsandboxed execution for git operations: + +Ran terminal command: git branch --show-current && git diff --stat + +On branch `edburns/review-copilot-pr-1766`, 2 files changed. Let me commit: + +Run in Terminal + +Created 3 todos + +Made changes. + +### 13:58 Prompt + +I believe this one was likely fixed by 9f745e85, but please confirm. Again, use `gh`. + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777548 + +### 14:00 Response + + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777548) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777548) | {id, path, line, body}' + +Confirmed — this is the same issue at line 317 (`generateClassSchema`), which was one of the three locations fixed in commit `9f745e85`. That commit changed all three `Map.of(...)` property constructions (parameters at line 97, records at line 278, and classes/POJOs at line 316) to `Map.ofEntries(Map.entry(...), ...)`. + +This comment is already resolved. Ready for the next one. + +### 14:02 Prompt + +This on is flagged as **Medium** but I think it's high. Please fix it, updating the test if necessary, and ensure the test runs without error or fail. + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777428 + +### 14:04 Response + +Updated todo list + +You're right — I'll stop re-requesting network for `gh` since it was already allowed. Let me continue adding the tests for the missing boxed types: + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#320-320), lines 320 to 350 + +Replacing 5 lines with 38 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` +Now run the tests: + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +All 27 tests pass (24 original + 3 new). Now spotless and commit: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk-00/java && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && ./mvnw spotless:apply -pl . && cd .. && git add java/src/main/java/com/github/copilot/tool/SchemaGenerator.java java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java && git commit -m "fix: add missing Byte/Short/Character boxed type mappings + +Address review comment r3461777428: Byte and Short now map to +\"integer\", Character maps to \"string\", matching their primitive +equivalents. Add tests for all three." + +Committed as `52a5bbc8`. Added `Byte`/`Short` → `"integer"` and `Character` → `"string"` mappings in `SchemaGenerator`, plus three new test cases. 27 tests pass, spotless clean. Ready for the next review comment. + +Created 4 todos + +Made changes. + +### 14:06 Prompt + +Here is another "max 10 params" one. Was this also fixed in 9f745e85 ? + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777514 + +### 14:08 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777514) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777514) | {id, path, line, body}' + +Yes — this is the record component case at line 279 (`generateRecordSchema`), which was one of the three locations fixed in commit `9f745e85`. Already resolved. + +### 14:08 Prompt + +Next: https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777459 + +Optional support is very important for idiomatic Java. + +### 14:10 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777459) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777459) | {id, path, line, body}' + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#185-185), lines 185 to 210 + +The review is correct — `OptionalLong` is handled in `isOptionalType()`/`unwrapOptional()` but missing from `generateDeclaredTypeSchema()`. If someone uses `OptionalLong` as a direct return type (not wrapped in a record), it falls through to POJO introspection. + +Replacing 5 lines with 8 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java) + + +``` + +``` +Now add tests for `OptionalLong` (and `OptionalInt`/`OptionalDouble` while we're at it, since they also lack dedicated tests): + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#410-410), lines 410 to 430 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#440-440), lines 440 to 465 + +Replacing 5 lines with 41 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` + + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +All 30 tests pass. Spotless and commit: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk-00/java && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && ./mvnw spotless:apply -pl . && cd .. && git add java/src/main/java/com/github/copilot/tool/SchemaGenerator.java java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java && git commit -m "fix: add missing OptionalLong mapping in generateDeclaredTypeSchema + +Address review comment r3461777459: OptionalLong was handled in +isOptionalType/unwrapOptional but missing from generateDeclaredTypeSchema, +causing it to fall through to POJO introspection when used as a direct +return type. Add the mapping and tests for OptionalInt, OptionalLong, +and OptionalDouble." + +Committed as `79063d7e`. Added the missing `OptionalLong` → `"integer"` mapping in `generateDeclaredTypeSchema`, plus tests for all three specialized Optional types (`OptionalInt`, `OptionalLong`, `OptionalDouble`). 30 tests pass. Ready for the next one. + +Created 1 todo + +Made changes. + +### 14:11 Prompt + +This on seems fishy: + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777579 + +### 14:13 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777579) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777579) | {id, path, line, body}' + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#240-240), lines 240 to 255 + +Indeed fishy — the comment says `@JsonSubTypes` but the implementation uses `getPermittedSubclasses()` (Java sealed types). It's just a misleading comment. + +Replacing 2 lines with 2 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java) + + +``` + +``` + + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk-00 && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && git add java/src/main/java/com/github/copilot/tool/SchemaGenerator.java && git commit -m "fix: correct misleading @JsonSubTypes comment on sealed interface handling + +Address review comment r3461777579: the implementation uses +getPermittedSubclasses() (Java sealed types), not Jackson annotations." + +Committed as `469b5f43`. The comment was indeed fishy — it referenced `@JsonSubTypes` (a Jackson annotation) but the actual implementation uses `getPermittedSubclasses()` from the Java sealed types API. Fixed to say "Sealed interfaces — oneOf via permitted subclasses". Ready for the next one. + +Created 2 todos + +Made changes. + +### 14:14 Prompt + +This one could be tricky. + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777656 + +We run the tests under both Java 25 and Java 17. See `copilot-sdk-00/.github/workflows/java-sdk-tests.yml`. + +I am inclined to not specify `--release` and let the system fall through to using the version of the JVM currently running. What do you think? + +### 14:19 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777656) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777656) | {id, path, line, body}' + +I agree with your instinct. Here's why not specifying `--release` is the right call: + +1. **The test snippets are release-agnostic.** They use basic types (`String`, `List`, `Map`, records, enums) that are identical at the `javax.lang.model` level regardless of whether the JDK is 17 or 25. There's no divergence risk in practice. + +2. **You can't pick a single value that works on both JDKs.** `--release 25` would fail on Java 17. `--release 17` would work on both but is unnecessary constraint. + +3. **`SchemaGenerator` operates on `TypeMirror`/`Elements` — not bytecode.** The release level of the compiled snippet doesn't affect the type model the processor sees. A `List` produces the same `DeclaredType` on release 17 and 25. + +4. **`compileAndCapture()` also doesn't use `--release`.** There's no inconsistency within the test class — the comment's premise ("consistent with the rest of the compilation-testing setup") doesn't hold since neither method uses it. + +I'd decline this comment with a short explanation that the tests run on both JDK 17 and 25, the snippets are release-agnostic, and omitting `--release` lets the tests naturally validate on whichever JDK is running. Want me to leave that reply on the PR, or just move on to the next comment? + +### 14:21 Prompt + + +This one seems worth doing. Do it. + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777685 + +### 14:23 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777685) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777685) | {id, path, line, body}' + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java#330-330), lines 330 to 345 + +Now I understand the `generateSealedSchema` logic. Let me add a sealed interface test. Since sealed interfaces require the permitted subclasses to be in the same compilation unit, I need to compile them together: + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#590-590), lines 590 to 610 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#610-610), lines 610 to 640 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#639-639), lines 639 to 647 + +Now I'll add the sealed interface test. The `compileAndCapture` method already supports multiple source files, and the `SchemaCapturingProcessor` captures types starting with `TestSealed`: + +Replacing 10 lines with 33 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` + + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +All 31 tests pass. Spotless and commit: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk-00/java && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && ./mvnw spotless:apply -pl . && cd .. && git add java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java && git commit -m "test: add sealed interface test for oneOf schema generation + +Address review comment r3461777685: the processor had special handling +for TestSealed* types but no test exercised generateSealedSchema(). +Add a test with a sealed interface (TestSealedShape) and two record +permits (Circle, Rect) verifying the oneOf schema output." + +Committed as `aca41772`. The sealed interface test exercises `generateSealedSchema()` with a `TestSealedShape` interface permitting `TestSealedCircle` and `TestSealedRect` records, asserting the `oneOf` schema output. 31 tests pass. Ready for the next one. + +Created 3 todos + +Made changes. + +### 14:24 Prompt + +This is the last one in this round: + +https://github.com/github/copilot-sdk/pull/1766#discussion_r3461777706 + +It's another ">10 property" one. However, it's worth reviewing for validity. + +### 14:25 Response + +Updated todo list + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777706) | {id, path, line, body}' + +Ran terminal command: gh api "repos/github/copilot-sdk/pulls/1766/comments" --paginate --jq '.[] | select(.id == 3461777706) | {id, path, line, body}' + +This comment asks for three categories of tests: + +1. **OptionalInt/OptionalDouble/OptionalLong** — ✅ Already added in commit `79063d7e` +2. **Boxed Byte/Short/Character** — ✅ Already added in commit `52a5bbc8` +3. **>10-property Map.of overload limit** — The code was fixed in `9f745e85` (uses `Map.ofEntries` now), but there's **no test proving a type with >10 fields actually compiles**. That's worth adding. + +Let me add a test with a record that has 11 components to validate the `Map.ofEntries` fix end-to-end: + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#500-500), lines 500 to 520 + +Read [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java#525-525), lines 525 to 545 + +Replacing 4 lines with 38 lines in [](file:///Users/edburns/workareas/copilot-sdk-00/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java) + + +``` + +``` + + +Ran terminal command: export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && ./mvnw test -Dtest=SchemaGeneratorTest -pl . + +All 32 tests pass. Spotless and commit: + +Ran terminal command: cd /Users/edburns/workareas/copilot-sdk-00/java && export JAVA_HOME="/Library/Java/JavaVirtualMachines/microsoft-25.jdk/Contents/Home" && export M2_HOME="/Users/edburns/Downloads/apache-maven-3.9.8" && export PATH="${M2_HOME}/bin:${JAVA_HOME}/bin:${PATH}" && ./mvnw spotless:apply -pl . && cd .. && git add java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java && git commit -m "test: add >10-field record test proving Map.ofEntries compiles + +Address review comment r3461777706: add a test with an 11-component +record that verifies the generated Map.ofEntries(...) expression +actually compiles, proving the Map.of 10-entry limit fix works +end-to-end." + +Committed as `d5feff27`. This test uses an 11-component record and **re-compiles** the generated schema expression to prove it's valid Java — directly validating the `Map.ofEntries` fix end-to-end. + +Summary of this round (all on `edburns/review-copilot-pr-1766`, not pushed): + +| Commit | Review Comment | Fix | +|--------|---------------|-----| +| `9f745e85` | r3461777483, r3461777548, r3461777514 | `Map.ofEntries` for all 3 property maps | +| `52a5bbc8` | r3461777428 | Missing `Byte`/`Short`/`Character` boxed type mappings | +| `79063d7e` | r3461777459 | Missing `OptionalLong` in `generateDeclaredTypeSchema` | +| `469b5f43` | r3461777579 | Misleading `@JsonSubTypes` comment | +| `aca41772` | r3461777685 | Sealed interface test | +| `d5feff27` | r3461777706 | >10-field record compilation test | + +Created 4 todos + +Made changes. From 7634198a1241a72dceb5c15853bedb651cc3616f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:18:10 +0000 Subject: [PATCH 18/29] Initial plan From 3ecfa57803169a0839eb40729e7bbc7640e90ce8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:31:19 +0000 Subject: [PATCH 19/29] feat(java): Add CopilotToolProcessor annotation processor (task 4.3) Implements JSR 269 annotation processor that finds @CopilotTool-annotated methods and generates $$CopilotToolMeta companion classes containing tool definitions, JSON Schema, and invocation lambdas. Key features: - snake_case tool name conversion from camelCase method names - Access level enforcement (compile error for private methods) - Return type handling (String, void, CompletableFuture, etc.) - Argument deserialization (direct cast for primitives/String, convertValue for complex) - @Param description and defaultValue support in schema - ToolDefer support (NONE maps to null/regular create) - overridesBuiltInTool and skipPermission support Also includes comprehensive test suite using javax.tools.JavaCompiler programmatic compilation. Closes github/copilot-sdk#1760 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../copilot/tool/CopilotToolProcessor.java | 577 ++++++++++++++++++ java/src/main/java/module-info.java | 3 +- .../javax.annotation.processing.Processor | 1 + .../tool/CopilotToolProcessorTest.java | 516 ++++++++++++++++ 4 files changed, 1096 insertions(+), 1 deletion(-) create mode 100644 java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java create mode 100644 java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java new file mode 100644 index 000000000..be5b419a9 --- /dev/null +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -0,0 +1,577 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; + +/** + * JSR 269 annotation processor that finds {@link CopilotTool}-annotated methods + * and generates {@code $$CopilotToolMeta} companion classes containing tool + * definitions, JSON Schema, and invocation lambdas. + * + *

+ * For a class {@code com.example.MyTools} containing {@code @CopilotTool} + * methods, this processor generates + * {@code com.example.MyTools$$CopilotToolMeta} in the same package. + * + * @since 1.0.2 + */ +@SupportedAnnotationTypes("com.github.copilot.tool.CopilotTool") +@SupportedSourceVersion(SourceVersion.RELEASE_17) +public class CopilotToolProcessor extends AbstractProcessor { + + private final SchemaGenerator schemaGenerator = new SchemaGenerator(); + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + for (Element element : roundEnv.getElementsAnnotatedWith(CopilotTool.class)) { + if (element.getKind() != ElementKind.METHOD) { + continue; + } + ExecutableElement method = (ExecutableElement) element; + + // Validate: private methods are not allowed + if (method.getModifiers().contains(Modifier.PRIVATE)) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotTool methods must not be private", method); + continue; + } + + // Validate @Param conflicts + for (VariableElement param : method.getParameters()) { + Param paramAnnotation = param.getAnnotation(Param.class); + if (paramAnnotation != null && paramAnnotation.required() && !paramAnnotation.defaultValue().isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@Param cannot have both required=true and a non-empty defaultValue", param); + } + } + } + + // Group methods by enclosing type + Map> methodsByClass = new LinkedHashMap<>(); + for (Element element : roundEnv.getElementsAnnotatedWith(CopilotTool.class)) { + if (element.getKind() != ElementKind.METHOD) { + continue; + } + ExecutableElement method = (ExecutableElement) element; + if (method.getModifiers().contains(Modifier.PRIVATE)) { + continue; + } + TypeElement enclosingType = (TypeElement) method.getEnclosingElement(); + methodsByClass.computeIfAbsent(enclosingType, k -> new ArrayList<>()).add(method); + } + + // Generate $$CopilotToolMeta for each class + for (Map.Entry> entry : methodsByClass.entrySet()) { + generateMetaClass(entry.getKey(), entry.getValue()); + } + + return false; + } + + private void generateMetaClass(TypeElement classElement, List methods) { + String packageName = processingEnv.getElementUtils().getPackageOf(classElement).getQualifiedName().toString(); + String simpleClassName = classElement.getSimpleName().toString(); + String metaClassName = simpleClassName + "$$CopilotToolMeta"; + String qualifiedMetaClassName = packageName.isEmpty() ? metaClassName : packageName + "." + metaClassName; + + try { + JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(qualifiedMetaClassName, classElement); + try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) { + writeMetaClass(out, packageName, simpleClassName, metaClassName, classElement, methods); + } + } catch (IOException e) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Failed to generate " + metaClassName + ": " + e.getMessage(), classElement); + } + } + + private void writeMetaClass(PrintWriter out, String packageName, String simpleClassName, String metaClassName, + TypeElement classElement, List methods) { + out.println("// GENERATED by CopilotToolProcessor — do not edit"); + + if (!packageName.isEmpty()) { + out.println("package " + packageName + ";"); + out.println(); + } + + out.println("import com.github.copilot.rpc.ToolDefinition;"); + out.println("import com.github.copilot.rpc.ToolDefer;"); + out.println("import com.fasterxml.jackson.databind.ObjectMapper;"); + out.println("import java.util.*;"); + out.println("import java.util.concurrent.CompletableFuture;"); + out.println(); + + out.println("final class " + metaClassName + " {"); + out.println(); + out.println(" private static final ObjectMapper objectMapper = new ObjectMapper();"); + out.println(); + + // Helper method for adding description/default to schema maps + if (needsWithMetaHelper(methods)) { + out.println( + " private static Map withMeta(Map base, String description, String defaultValue) {"); + out.println(" var result = new LinkedHashMap(base);"); + out.println(" if (description != null) result.put(\"description\", description);"); + out.println(" if (defaultValue != null) result.put(\"default\", defaultValue);"); + out.println(" return Collections.unmodifiableMap(result);"); + out.println(" }"); + out.println(); + } + + // definitions method + out.println(" static List definitions(" + simpleClassName + " instance) {"); + out.println(" return List.of("); + + for (int i = 0; i < methods.size(); i++) { + ExecutableElement method = methods.get(i); + writeToolDefinition(out, method); + if (i < methods.size() - 1) { + out.println(","); + } else { + out.println(); + } + } + + out.println(" );"); + out.println(" }"); + out.println("}"); + } + + private boolean needsWithMetaHelper(List methods) { + for (ExecutableElement method : methods) { + for (VariableElement param : method.getParameters()) { + Param paramAnnotation = param.getAnnotation(Param.class); + if (paramAnnotation != null && (!paramAnnotation.value().isEmpty() + || !paramAnnotation.defaultValue().isEmpty())) { + return true; + } + } + } + return false; + } + + private void writeToolDefinition(PrintWriter out, ExecutableElement method) { + CopilotTool annotation = method.getAnnotation(CopilotTool.class); + String toolName = annotation.name().isEmpty() ? toSnakeCase(method.getSimpleName().toString()) + : annotation.name(); + String description = annotation.value(); + boolean overridesBuiltIn = annotation.overridesBuiltInTool(); + boolean skipPermission = annotation.skipPermission(); + com.github.copilot.rpc.ToolDefer defer = annotation.defer(); + + // Generate schema with @Param metadata (descriptions, names, defaults) + String schemaSource = generateSchemaWithParamMetadata(method.getParameters()); + + // Generate invocation lambda + String lambdaBody = generateLambdaBody(method); + + // Determine factory method and arguments + out.print(" "); + if (overridesBuiltIn) { + out.println("ToolDefinition.createOverride("); + } else if (skipPermission) { + out.println("ToolDefinition.createSkipPermission("); + } else if (defer != com.github.copilot.rpc.ToolDefer.NONE) { + out.println("ToolDefinition.createWithDefer("); + } else { + out.println("ToolDefinition.create("); + } + + out.println(" \"" + escapeJava(toolName) + "\","); + out.println(" \"" + escapeJava(description) + "\","); + out.println(" " + schemaSource + ","); + out.println(" invocation -> {"); + out.println(" " + lambdaBody); + out.println(" }"); + + // Add defer parameter if needed + if (defer != com.github.copilot.rpc.ToolDefer.NONE && !overridesBuiltIn && !skipPermission) { + out.println(" , ToolDefer." + defer.name()); + } + + out.print(" )"); + } + + private String generateSchemaWithParamMetadata(List parameters) { + if (parameters.isEmpty()) { + return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())"; + } + + List propertyEntries = new ArrayList<>(); + List requiredNames = new ArrayList<>(); + + for (VariableElement param : parameters) { + String paramName = getParamName(param); + TypeMirror paramType = param.asType(); + Param paramAnnotation = param.getAnnotation(Param.class); + + // Generate the type schema for this parameter + String typeSchema = schemaGenerator.generateSchemaSource(paramType, processingEnv.getTypeUtils(), + processingEnv.getElementUtils()); + + // Build property schema with description and default if present + String propertySchema = buildPropertySchema(typeSchema, paramAnnotation); + + propertyEntries.add("Map.entry(\"" + paramName + "\", " + propertySchema + ")"); + + // Determine if required + if (paramAnnotation == null || paramAnnotation.required()) { + requiredNames.add("\"" + paramName + "\""); + } + } + + String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; + String required = "List.of(" + String.join(", ", requiredNames) + ")"; + + return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; + } + + private String buildPropertySchema(String typeSchema, Param paramAnnotation) { + if (paramAnnotation == null) { + return typeSchema; + } + + String desc = paramAnnotation.value(); + String defaultValue = paramAnnotation.defaultValue(); + + boolean hasDescription = !desc.isEmpty(); + boolean hasDefault = !defaultValue.isEmpty(); + + if (!hasDescription && !hasDefault) { + return typeSchema; + } + + // Use the withMeta helper method in the generated class + String descArg = hasDescription ? "\"" + escapeJava(desc) + "\"" : "null"; + String defaultArg = hasDefault ? "\"" + escapeJava(defaultValue) + "\"" : "null"; + + return "withMeta(" + typeSchema + ", " + descArg + ", " + defaultArg + ")"; + } + + private String generateLambdaBody(ExecutableElement method) { + List params = method.getParameters(); + StringBuilder sb = new StringBuilder(); + + // Generate argument extraction + if (!params.isEmpty()) { + sb.append("Map args = invocation.getArguments();\n"); + + // Check if single-record-parameter shortcut applies + if (params.size() == 1 && isRecordOrPojo(params.get(0).asType())) { + String typeName = getTypeString(params.get(0).asType()); + String paramName = params.get(0).getSimpleName().toString(); + sb.append(" ").append(typeName).append(" ").append(paramName) + .append(" = invocation.getArgumentsAs(").append(typeName).append(".class);\n"); + } else { + for (VariableElement param : params) { + String paramName = getParamName(param); + String varName = param.getSimpleName().toString(); + TypeMirror paramType = param.asType(); + + // Handle default values + Param paramAnnotation = param.getAnnotation(Param.class); + boolean hasDefault = paramAnnotation != null && !paramAnnotation.defaultValue().isEmpty(); + + if (hasDefault) { + String defaultValue = paramAnnotation.defaultValue(); + sb.append(" Object ").append(varName).append("Raw = args.containsKey(\"") + .append(paramName).append("\") ? args.get(\"").append(paramName).append("\") : ") + .append(generateDefaultLiteral(paramType, defaultValue)).append(";\n"); + sb.append(" ").append(getTypeString(paramType)).append(" ").append(varName) + .append(" = ").append(generateArgExtraction(varName + "Raw", paramType)).append(";\n"); + } else { + sb.append(" ").append(getTypeString(paramType)).append(" ").append(varName) + .append(" = ").append(generateArgExtractionFromMap(paramName, paramType)).append(";\n"); + } + } + } + } + + // Generate method invocation based on return type + TypeMirror returnType = method.getReturnType(); + String methodCall = "instance." + method.getSimpleName() + "(" + generateArgList(params) + ")"; + + if (returnType.getKind() == TypeKind.VOID) { + sb.append(" ").append(methodCall).append(";\n"); + sb.append(" return CompletableFuture.completedFuture(\"Success\");"); + } else if (isCompletableFuture(returnType)) { + TypeMirror typeArg = getCompletableFutureTypeArg(returnType); + if (typeArg != null && isStringType(typeArg)) { + // CompletableFuture -> CompletableFuture via thenApply + sb.append(" return ").append(methodCall) + .append(".thenApply(r -> (Object) r);"); + } else { + // CompletableFuture -> serialize to JSON + sb.append(" return ").append(methodCall) + .append(".thenApply(r -> { try { return (Object) objectMapper.writeValueAsString(r); }") + .append(" catch (Exception e) { throw new RuntimeException(e); } });"); + } + } else if (isStringType(returnType)) { + sb.append(" return CompletableFuture.completedFuture(").append(methodCall).append(");"); + } else { + sb.append(" try { return CompletableFuture.completedFuture(objectMapper.writeValueAsString(") + .append(methodCall) + .append(")); } catch (Exception e) { throw new RuntimeException(e); }"); + } + + return sb.toString(); + } + + private String generateArgList(List params) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(params.get(i).getSimpleName().toString()); + } + return sb.toString(); + } + + private String generateArgExtractionFromMap(String paramName, TypeMirror type) { + if (type.getKind().isPrimitive()) { + return generatePrimitiveExtraction("args.get(\"" + paramName + "\")", type); + } + if (type.getKind() == TypeKind.DECLARED) { + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + if ("java.lang.String".equals(qualifiedName)) { + return "(String) args.get(\"" + paramName + "\")"; + } + if (isBoxedNumeric(qualifiedName)) { + return generateBoxedNumericExtraction("args.get(\"" + paramName + "\")", qualifiedName); + } + if ("java.lang.Boolean".equals(qualifiedName)) { + return "(Boolean) args.get(\"" + paramName + "\")"; + } + // Complex types: enums, records, POJOs + return "objectMapper.convertValue(args.get(\"" + paramName + "\"), " + qualifiedName + ".class)"; + } + return "(Object) args.get(\"" + paramName + "\")"; + } + + private String generateArgExtraction(String varExpr, TypeMirror type) { + if (type.getKind().isPrimitive()) { + return generatePrimitiveExtraction(varExpr, type); + } + if (type.getKind() == TypeKind.DECLARED) { + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + if ("java.lang.String".equals(qualifiedName)) { + return "(String) " + varExpr; + } + if (isBoxedNumeric(qualifiedName)) { + return generateBoxedNumericExtraction(varExpr, qualifiedName); + } + if ("java.lang.Boolean".equals(qualifiedName)) { + return "(Boolean) " + varExpr; + } + return "objectMapper.convertValue(" + varExpr + ", " + qualifiedName + ".class)"; + } + return "(Object) " + varExpr; + } + + private String generatePrimitiveExtraction(String expr, TypeMirror type) { + switch (type.getKind()) { + case INT : + return "((Number) " + expr + ").intValue()"; + case LONG : + return "((Number) " + expr + ").longValue()"; + case DOUBLE : + return "((Number) " + expr + ").doubleValue()"; + case FLOAT : + return "((Number) " + expr + ").floatValue()"; + case SHORT : + return "((Number) " + expr + ").shortValue()"; + case BYTE : + return "((Number) " + expr + ").byteValue()"; + case BOOLEAN : + return "(Boolean) " + expr; + case CHAR : + return "((String) " + expr + ").charAt(0)"; + default : + return "(" + type + ") " + expr; + } + } + + private boolean isBoxedNumeric(String qualifiedName) { + return "java.lang.Integer".equals(qualifiedName) || "java.lang.Long".equals(qualifiedName) + || "java.lang.Double".equals(qualifiedName) || "java.lang.Float".equals(qualifiedName) + || "java.lang.Short".equals(qualifiedName) || "java.lang.Byte".equals(qualifiedName); + } + + private String generateBoxedNumericExtraction(String expr, String qualifiedName) { + switch (qualifiedName) { + case "java.lang.Integer" : + return "((Number) " + expr + ").intValue()"; + case "java.lang.Long" : + return "((Number) " + expr + ").longValue()"; + case "java.lang.Double" : + return "((Number) " + expr + ").doubleValue()"; + case "java.lang.Float" : + return "((Number) " + expr + ").floatValue()"; + case "java.lang.Short" : + return "((Number) " + expr + ").shortValue()"; + case "java.lang.Byte" : + return "((Number) " + expr + ").byteValue()"; + default : + return "(" + qualifiedName + ") " + expr; + } + } + + private String generateDefaultLiteral(TypeMirror type, String defaultValue) { + if (type.getKind().isPrimitive()) { + switch (type.getKind()) { + case INT : + case LONG : + case SHORT : + case BYTE : + return defaultValue; + case DOUBLE : + case FLOAT : + return defaultValue; + case BOOLEAN : + return defaultValue; + case CHAR : + return "\"" + escapeJava(defaultValue) + "\""; + default : + return "\"" + escapeJava(defaultValue) + "\""; + } + } + if (type.getKind() == TypeKind.DECLARED) { + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + if ("java.lang.String".equals(qualifiedName)) { + return "\"" + escapeJava(defaultValue) + "\""; + } + if (isBoxedNumeric(qualifiedName) || "java.lang.Boolean".equals(qualifiedName)) { + return defaultValue; + } + } + return "\"" + escapeJava(defaultValue) + "\""; + } + + private String getParamName(VariableElement param) { + Param paramAnnotation = param.getAnnotation(Param.class); + if (paramAnnotation != null && !paramAnnotation.name().isEmpty()) { + return paramAnnotation.name(); + } + return param.getSimpleName().toString(); + } + + private String getTypeString(TypeMirror type) { + if (type.getKind().isPrimitive()) { + return type.toString(); + } + if (type.getKind() == TypeKind.DECLARED) { + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + return typeElement.getSimpleName().toString(); + } + return type.toString(); + } + + private boolean isRecordOrPojo(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED) { + return false; + } + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + return typeElement.getKind() == ElementKind.RECORD || (typeElement.getKind() == ElementKind.CLASS + && !isSimpleType(typeElement.getQualifiedName().toString())); + } + + private boolean isSimpleType(String qualifiedName) { + return "java.lang.String".equals(qualifiedName) || "java.lang.Integer".equals(qualifiedName) + || "java.lang.Long".equals(qualifiedName) || "java.lang.Double".equals(qualifiedName) + || "java.lang.Float".equals(qualifiedName) || "java.lang.Boolean".equals(qualifiedName) + || "java.lang.Short".equals(qualifiedName) || "java.lang.Byte".equals(qualifiedName) + || "java.lang.Character".equals(qualifiedName) || "java.lang.Object".equals(qualifiedName); + } + + private boolean isCompletableFuture(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED) { + return false; + } + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + return "java.util.concurrent.CompletableFuture".equals(typeElement.getQualifiedName().toString()); + } + + private TypeMirror getCompletableFutureTypeArg(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED) { + return null; + } + DeclaredType declaredType = (DeclaredType) type; + List typeArgs = declaredType.getTypeArguments(); + if (typeArgs.isEmpty()) { + return null; + } + return typeArgs.get(0); + } + + private boolean isStringType(TypeMirror type) { + if (type.getKind() != TypeKind.DECLARED) { + return false; + } + TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); + return "java.lang.String".equals(typeElement.getQualifiedName().toString()); + } + + /** + * Converts a camelCase method name to snake_case. + * + * @param name + * the method name + * @return the snake_case tool name + */ + static String toSnakeCase(String name) { + if (name == null || name.isEmpty()) { + return name; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) { + sb.append('_'); + } + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String escapeJava(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", + "\\t"); + } +} diff --git a/java/src/main/java/module-info.java b/java/src/main/java/module-info.java index e3a7bc5fc..38bc1f93d 100644 --- a/java/src/main/java/module-info.java +++ b/java/src/main/java/module-info.java @@ -26,5 +26,6 @@ opens com.github.copilot.generated.rpc to com.fasterxml.jackson.databind; opens com.github.copilot.rpc to com.fasterxml.jackson.databind; - provides javax.annotation.processing.Processor with com.github.copilot.CopilotExperimentalProcessor; + provides javax.annotation.processing.Processor + with com.github.copilot.CopilotExperimentalProcessor, com.github.copilot.tool.CopilotToolProcessor; } diff --git a/java/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/java/src/main/resources/META-INF/services/javax.annotation.processing.Processor index 1e7feda8c..3b2e17d2f 100644 --- a/java/src/main/resources/META-INF/services/javax.annotation.processing.Processor +++ b/java/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -1 +1,2 @@ com.github.copilot.CopilotExperimentalProcessor +com.github.copilot.tool.CopilotToolProcessor diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java new file mode 100644 index 000000000..875094c69 --- /dev/null +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -0,0 +1,516 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import java.security.CodeSource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests that {@link CopilotToolProcessor} correctly generates + * {@code $$CopilotToolMeta} companion classes and emits compile errors for + * invalid usages. + */ +class CopilotToolProcessorTest { + + @TempDir + java.nio.file.Path tempDir; + + // ── Test: Basic generation ────────────────────────────────────────────────── + + @Test + void generatesMetaClass_withCorrectToolNames() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class MyTools { + @CopilotTool("Sets the current phase") + public String setCurrentPhase(@Param("The phase") String phase) { + return "done"; + } + @CopilotTool("Search for items") + public String searchItems(@Param("Keyword") String keyword) { + return "found"; + } + @CopilotTool(value = "Custom grep", name = "grep") + public String grepOverride(@Param("Query") String query) { + return "result"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.MyTools", source))); + + assertNoErrors(result); + // Verify generated source contains the expected tool names + String generated = result.getGeneratedSource("test.MyTools$$CopilotToolMeta"); + assertTrue(generated != null, "Expected $$CopilotToolMeta to be generated"); + assertTrue(generated.contains("\"set_current_phase\""), "Expected snake_case name: set_current_phase"); + assertTrue(generated.contains("\"search_items\""), "Expected snake_case name: search_items"); + assertTrue(generated.contains("\"grep\""), "Expected explicit name: grep"); + } + + // ── Test: Compile error for private methods ───────────────────────────────── + + @Test + void emitsError_forPrivateMethods() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + public class PrivateTools { + @CopilotTool("Private tool") + private String doSomething() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.PrivateTools", source))); + + assertTrue(hasErrorContaining(result, "must not be private"), + "Expected compile error for private @CopilotTool method, got: " + result.diagnostics); + } + + // ── Test: Compile error for required + defaultValue conflict ───────────── + + @Test + void emitsError_forRequiredWithDefaultValue() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class ConflictTools { + @CopilotTool("Conflicting params") + public String doSomething(@Param(value = "desc", required = true, defaultValue = "hello") String param) { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ConflictTools", source))); + + assertTrue(hasErrorContaining(result, "required=true"), + "Expected compile error for required+defaultValue conflict, got: " + result.diagnostics); + } + + // ── Test: Return type handling ────────────────────────────────────────────── + + @Test + void generatesCorrectCode_forStringReturnType() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class StringReturn { + @CopilotTool("Returns string") + public String doSomething(@Param("Input") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.StringReturn", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.StringReturn$$CopilotToolMeta"); + assertTrue(generated.contains("CompletableFuture.completedFuture(instance.doSomething("), + "Expected completedFuture wrapping for String return, got:\n" + generated); + } + + @Test + void generatesCorrectCode_forVoidReturnType() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class VoidReturn { + @CopilotTool("Void method") + public void doSomething(@Param("Input") String input) { + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.VoidReturn", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.VoidReturn$$CopilotToolMeta"); + assertTrue(generated.contains("instance.doSomething("), "Expected method call in generated code"); + assertTrue(generated.contains("CompletableFuture.completedFuture(\"Success\")"), + "Expected 'Success' return for void methods, got:\n" + generated); + } + + @Test + void generatesCorrectCode_forCompletableFutureStringReturnType() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + import java.util.concurrent.CompletableFuture; + public class AsyncReturn { + @CopilotTool("Async method") + public CompletableFuture doSomething(@Param("Input") String input) { + return CompletableFuture.completedFuture(input); + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.AsyncReturn", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.AsyncReturn$$CopilotToolMeta"); + assertTrue(generated.contains("return instance.doSomething("), + "Expected direct return for CompletableFuture, got:\n" + generated); + assertTrue(generated.contains("thenApply(r -> (Object) r)"), + "Expected thenApply cast for CompletableFuture, got:\n" + generated); + } + + @Test + void generatesCorrectCode_forIntReturnType() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class IntReturn { + @CopilotTool("Returns int") + public int doSomething(@Param("Input") String input) { + return 42; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.IntReturn", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.IntReturn$$CopilotToolMeta"); + assertTrue(generated.contains("objectMapper.writeValueAsString(instance.doSomething("), + "Expected JSON serialization for int return type, got:\n" + generated); + } + + // ── Test: Argument coercion ───────────────────────────────────────────────── + + @Test + void generatesCorrectArgExtraction_forPrimitiveAndStringTypes() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class ArgTypes { + @CopilotTool("Mixed args") + public String doSomething( + @Param("Name") String name, + @Param("Count") int count, + @Param("Flag") boolean flag) { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ArgTypes", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.ArgTypes$$CopilotToolMeta"); + assertTrue(generated.contains("(String) args.get(\"name\")"), + "Expected String cast for String param, got:\n" + generated); + assertTrue(generated.contains("((Number) args.get(\"count\")).intValue()"), + "Expected Number cast for int param, got:\n" + generated); + assertTrue(generated.contains("(Boolean) args.get(\"flag\")"), + "Expected Boolean cast for boolean param, got:\n" + generated); + } + + // ── Test: snake_case conversion ───────────────────────────────────────────── + + @Test + void snakeCaseConversion() { + assertEquals("set_current_phase", CopilotToolProcessor.toSnakeCase("setCurrentPhase")); + assertEquals("search_items", CopilotToolProcessor.toSnakeCase("searchItems")); + assertEquals("grep", CopilotToolProcessor.toSnakeCase("grep")); + assertEquals("get_u_r_l", CopilotToolProcessor.toSnakeCase("getURL")); + assertEquals("a", CopilotToolProcessor.toSnakeCase("a")); + assertEquals("", CopilotToolProcessor.toSnakeCase("")); + } + + // ── Test: Processor registration ──────────────────────────────────────────── + + @Test + void processorIsRegisteredInMetaInfServices() throws Exception { + var resource = getClass().getClassLoader() + .getResource("META-INF/services/javax.annotation.processing.Processor"); + assertTrue(resource != null, "META-INF/services/javax.annotation.processing.Processor should exist"); + String content = new String(resource.openStream().readAllBytes()); + assertTrue(content.contains("com.github.copilot.tool.CopilotToolProcessor"), + "Service file should contain CopilotToolProcessor"); + } + + // ── Test: Schema generation in generated code ─────────────────────────────── + + @Test + void generatesCorrectSchema() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class SchemaTools { + @CopilotTool("Search items") + public String search( + @Param(value = "Query", required = true) String query, + @Param(value = "Limit", required = false) int limit) { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.SchemaTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.SchemaTools$$CopilotToolMeta"); + // Verify the schema contains the expected keys + assertTrue(generated.contains("\"type\", \"object\""), "Expected object type in schema"); + assertTrue(generated.contains("\"properties\""), "Expected properties in schema"); + assertTrue(generated.contains("\"required\""), "Expected required in schema"); + assertTrue(generated.contains("\"query\""), "Expected query property"); + } + + // ── Test: package-private methods are allowed ─────────────────────────────── + + @Test + void allowsPackagePrivateMethods() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + public class PackagePrivateTools { + @CopilotTool("Package private tool") + String doSomething() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.PackagePrivateTools", source))); + assertNoErrors(result); + } + + // ── Test: protected methods are allowed ───────────────────────────────────── + + @Test + void allowsProtectedMethods() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + public class ProtectedTools { + @CopilotTool("Protected tool") + protected String doSomething() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ProtectedTools", source))); + assertNoErrors(result); + } + + // ── Test: overridesBuiltInTool generates createOverride ───────────────────── + + @Test + void generatesCreateOverride_whenOverridesBuiltInTool() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class OverrideTools { + @CopilotTool(value = "Custom grep", name = "grep", overridesBuiltInTool = true) + public String grep(@Param("Query") String query) { + return "result"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.OverrideTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.OverrideTools$$CopilotToolMeta"); + assertTrue(generated.contains("ToolDefinition.createOverride("), + "Expected createOverride factory method, got:\n" + generated); + } + + // ── Test: ToolDefer.NONE results in regular create ────────────────────────── + + @Test + void generatesCreate_whenDeferIsNone() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.rpc.ToolDefer; + public class DeferNoneTools { + @CopilotTool(value = "Simple tool", defer = ToolDefer.NONE) + public String doSomething() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.DeferNoneTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.DeferNoneTools$$CopilotToolMeta"); + assertTrue(generated.contains("ToolDefinition.create("), + "Expected create (not createWithDefer) for NONE, got:\n" + generated); + assertFalse(generated.contains("createWithDefer"), + "Should NOT use createWithDefer for NONE, got:\n" + generated); + } + + // ── Test: ToolDefer.AUTO results in createWithDefer ────────────────────────── + + @Test + void generatesCreateWithDefer_whenDeferIsAuto() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.rpc.ToolDefer; + public class DeferAutoTools { + @CopilotTool(value = "Deferrable tool", defer = ToolDefer.AUTO) + public String doSomething() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.DeferAutoTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.DeferAutoTools$$CopilotToolMeta"); + assertTrue(generated.contains("ToolDefinition.createWithDefer("), + "Expected createWithDefer for AUTO, got:\n" + generated); + assertTrue(generated.contains("ToolDefer.AUTO"), "Expected ToolDefer.AUTO argument, got:\n" + generated); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private CompilationResult compileWithProcessor(List sources) { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + String classpath = resolveClasspath(); + List options = new ArrayList<>(); + options.addAll(List.of("-classpath", classpath)); + options.addAll(List.of("-d", tempDir.toString())); + options.addAll(List.of("-s", tempDir.toString())); + // Allow experimental APIs during test compilation + options.add("-Acopilot.experimental.allowed=true"); + + try { + StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null); + fileManager.setLocation(StandardLocation.SOURCE_OUTPUT, List.of(tempDir.toFile())); + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(tempDir.toFile())); + + JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnostics, options, null, + sources); + task.setProcessors(List.of(new CopilotToolProcessor())); + task.call(); + + // Collect generated sources + List generatedSources = new ArrayList<>(); + collectGeneratedFiles(tempDir, generatedSources); + + return new CompilationResult(diagnostics.getDiagnostics(), generatedSources, tempDir); + } catch (Exception e) { + throw new RuntimeException("Compilation setup failed", e); + } + } + + private void collectGeneratedFiles(java.nio.file.Path dir, List files) { + try { + java.nio.file.Files.walk(dir).filter(p -> p.toString().endsWith(".java")).forEach(p -> { + try { + files.add(java.nio.file.Files.readString(p)); + } catch (java.io.IOException e) { + // ignore + } + }); + } catch (java.io.IOException e) { + // ignore + } + } + + private static String resolveClasspath() { + CodeSource cs = CopilotTool.class.getProtectionDomain().getCodeSource(); + if (cs != null) { + URL location = cs.getLocation(); + if (location != null) { + try { + return Path.of(location.toURI()).toString(); + } catch (Exception ignored) { + // fall through + } + } + } + return System.getProperty("java.class.path", "."); + } + + private static JavaFileObject inMemorySource(String className, String code) { + return new SimpleJavaFileObject(URI.create("string:///" + className.replace('.', '/') + ".java"), + JavaFileObject.Kind.SOURCE) { + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + }; + } + + private static void assertNoErrors(CompilationResult result) { + List> errors = result.diagnostics.stream() + .filter(d -> d.getKind() == Diagnostic.Kind.ERROR).toList(); + assertTrue(errors.isEmpty(), "Expected no errors, got: " + errors); + } + + private static boolean hasErrorContaining(CompilationResult result, String substring) { + return result.diagnostics.stream().anyMatch( + d -> d.getKind() == Diagnostic.Kind.ERROR && d.getMessage(null).contains(substring)); + } + + private static class CompilationResult { + final List> diagnostics; + final List generatedSources; + final java.nio.file.Path outputDir; + + CompilationResult(List> diagnostics, List generatedSources, + java.nio.file.Path outputDir) { + this.diagnostics = diagnostics; + this.generatedSources = generatedSources; + this.outputDir = outputDir; + } + + String getGeneratedSource(String qualifiedName) { + String fileName = qualifiedName.replace('.', '/') + ".java"; + java.nio.file.Path filePath = outputDir.resolve(fileName); + try { + if (java.nio.file.Files.exists(filePath)) { + return java.nio.file.Files.readString(filePath); + } + } catch (java.io.IOException e) { + // fall through + } + // Also check in collected sources + for (String source : generatedSources) { + if (source.contains(qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1) + "$$CopilotToolMeta") + || source.contains("class " + qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1))) { + return source; + } + } + return null; + } + } +} From eaa25b66e1e71b9aa49af3b3da5a6ab2af88b2c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:35:32 +0000 Subject: [PATCH 20/29] fix: Address code review feedback - Use fully qualified type names in generated code for type safety - Fix Files.walk() resource leak in test with try-with-resources - Rename exception variables for clarity Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../github/copilot/tool/CopilotToolProcessor.java | 7 +++++-- .../copilot/tool/CopilotToolProcessorTest.java | 12 ++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index be5b419a9..3180aa32a 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -145,6 +145,7 @@ private void writeMetaClass(PrintWriter out, String packageName, String simpleCl } // definitions method + out.println(" @SuppressWarnings({\"unchecked\", \"rawtypes\"})"); out.println(" static List definitions(" + simpleClassName + " instance) {"); out.println(" return List.of("); @@ -238,7 +239,9 @@ private String generateSchemaWithParamMetadata(List p // Build property schema with description and default if present String propertySchema = buildPropertySchema(typeSchema, paramAnnotation); - propertyEntries.add("Map.entry(\"" + paramName + "\", " + propertySchema + ")"); + // Cast to Map via raw type for consistent Map.ofEntries typing + propertyEntries + .add("Map.entry(\"" + paramName + "\", (Map)(Map) " + propertySchema + ")"); // Determine if required if (paramAnnotation == null || paramAnnotation.required()) { @@ -491,7 +494,7 @@ private String getTypeString(TypeMirror type) { } if (type.getKind() == TypeKind.DECLARED) { TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); - return typeElement.getSimpleName().toString(); + return typeElement.getQualifiedName().toString(); } return type.toString(); } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 875094c69..a0a2dc1ec 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -432,16 +432,16 @@ private CompilationResult compileWithProcessor(List sources) { } private void collectGeneratedFiles(java.nio.file.Path dir, List files) { - try { - java.nio.file.Files.walk(dir).filter(p -> p.toString().endsWith(".java")).forEach(p -> { + try (var stream = java.nio.file.Files.walk(dir)) { + stream.filter(p -> p.toString().endsWith(".java")).forEach(p -> { try { files.add(java.nio.file.Files.readString(p)); } catch (java.io.IOException e) { - // ignore + // ignore read errors for generated file collection } }); } catch (java.io.IOException e) { - // ignore + // ignore walk errors } } @@ -452,8 +452,8 @@ private static String resolveClasspath() { if (location != null) { try { return Path.of(location.toURI()).toString(); - } catch (Exception ignored) { - // fall through + } catch (Exception e) { + // fall through to system classpath } } } From 027a45b89571df57d71e44558e5720703a561a5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:15:14 +0000 Subject: [PATCH 21/29] fix: Fix Spotless formatting and test classpath for JDK 17 - Remove unused Collections import - Reformat boolean expressions: && at start of continuation lines - Reformat ternary: ? at start of continuation line - Reformat .replace() chain with one call per line - Fix hasErrorContaining stream method chain formatting - Fix resolveClasspath() to use System.getProperty("java.class.path") first, ensuring Jackson and all test deps are available when compiling generated $$CopilotToolMeta code --- .../copilot/tool/CopilotToolProcessor.java | 17 +++++++++++------ .../copilot/tool/CopilotToolProcessorTest.java | 15 ++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 3180aa32a..7c97820f2 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -65,7 +65,8 @@ public boolean process(Set annotations, RoundEnvironment // Validate @Param conflicts for (VariableElement param : method.getParameters()) { Param paramAnnotation = param.getAnnotation(Param.class); - if (paramAnnotation != null && paramAnnotation.required() && !paramAnnotation.defaultValue().isEmpty()) { + if (paramAnnotation != null && paramAnnotation.required() + && !paramAnnotation.defaultValue().isEmpty()) { processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Param cannot have both required=true and a non-empty defaultValue", param); } @@ -168,8 +169,8 @@ private boolean needsWithMetaHelper(List methods) { for (ExecutableElement method : methods) { for (VariableElement param : method.getParameters()) { Param paramAnnotation = param.getAnnotation(Param.class); - if (paramAnnotation != null && (!paramAnnotation.value().isEmpty() - || !paramAnnotation.defaultValue().isEmpty())) { + if (paramAnnotation != null + && (!paramAnnotation.value().isEmpty() || !paramAnnotation.defaultValue().isEmpty())) { return true; } } @@ -179,7 +180,8 @@ private boolean needsWithMetaHelper(List methods) { private void writeToolDefinition(PrintWriter out, ExecutableElement method) { CopilotTool annotation = method.getAnnotation(CopilotTool.class); - String toolName = annotation.name().isEmpty() ? toSnakeCase(method.getSimpleName().toString()) + String toolName = annotation.name().isEmpty() + ? toSnakeCase(method.getSimpleName().toString()) : annotation.name(); String description = annotation.value(); boolean overridesBuiltIn = annotation.overridesBuiltInTool(); @@ -574,7 +576,10 @@ private static String escapeJava(String s) { if (s == null) { return ""; } - return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", - "\\t"); + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); } } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index a0a2dc1ec..7fee76b1b 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -13,7 +13,6 @@ import java.nio.file.Path; import java.security.CodeSource; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import javax.tools.Diagnostic; @@ -446,6 +445,12 @@ private void collectGeneratedFiles(java.nio.file.Path dir, List files) { } private static String resolveClasspath() { + // Use the full system classpath so generated code can resolve all + // dependencies (e.g. Jackson ObjectMapper) during test compilation. + String systemCp = System.getProperty("java.class.path", ""); + if (!systemCp.isEmpty()) { + return systemCp; + } CodeSource cs = CopilotTool.class.getProtectionDomain().getCodeSource(); if (cs != null) { URL location = cs.getLocation(); @@ -453,11 +458,11 @@ private static String resolveClasspath() { try { return Path.of(location.toURI()).toString(); } catch (Exception e) { - // fall through to system classpath + return "."; } } } - return System.getProperty("java.class.path", "."); + return "."; } private static JavaFileObject inMemorySource(String className, String code) { @@ -477,8 +482,8 @@ private static void assertNoErrors(CompilationResult result) { } private static boolean hasErrorContaining(CompilationResult result, String substring) { - return result.diagnostics.stream().anyMatch( - d -> d.getKind() == Diagnostic.Kind.ERROR && d.getMessage(null).contains(substring)); + return result.diagnostics.stream() + .anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR && d.getMessage(null).contains(substring)); } private static class CompilationResult { From 903740b41e27ebaf81725bc76962b6be1045a359 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:24:36 +0000 Subject: [PATCH 22/29] fix: Fix remaining Spotless violations and test classpath resolution - Merge propertyEntries.add() onto one line per formatter requirement - Fix sb.append() chain formatting to match Eclipse formatter output - Revert escapeJava to original line-breaking style (formatter preference) - Fix resolveClasspath() to combine system classpath with CodeSource paths from key classes (SDK, Jackson, RPC types) ensuring all dependencies are available for javac in the annotation processor test --- .../copilot/tool/CopilotToolProcessor.java | 19 ++++----- .../tool/CopilotToolProcessorTest.java | 39 ++++++++++++------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 7c97820f2..c5cd890db 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -242,8 +242,7 @@ private String generateSchemaWithParamMetadata(List p String propertySchema = buildPropertySchema(typeSchema, paramAnnotation); // Cast to Map via raw type for consistent Map.ofEntries typing - propertyEntries - .add("Map.entry(\"" + paramName + "\", (Map)(Map) " + propertySchema + ")"); + propertyEntries.add("Map.entry(\"" + paramName + "\", (Map)(Map) " + propertySchema + ")"); // Determine if required if (paramAnnotation == null || paramAnnotation.required()) { @@ -329,8 +328,7 @@ private String generateLambdaBody(ExecutableElement method) { TypeMirror typeArg = getCompletableFutureTypeArg(returnType); if (typeArg != null && isStringType(typeArg)) { // CompletableFuture -> CompletableFuture via thenApply - sb.append(" return ").append(methodCall) - .append(".thenApply(r -> (Object) r);"); + sb.append(" return ").append(methodCall).append(".thenApply(r -> (Object) r);"); } else { // CompletableFuture -> serialize to JSON sb.append(" return ").append(methodCall) @@ -340,9 +338,9 @@ private String generateLambdaBody(ExecutableElement method) { } else if (isStringType(returnType)) { sb.append(" return CompletableFuture.completedFuture(").append(methodCall).append(");"); } else { - sb.append(" try { return CompletableFuture.completedFuture(objectMapper.writeValueAsString(") - .append(methodCall) - .append(")); } catch (Exception e) { throw new RuntimeException(e); }"); + sb.append( + " try { return CompletableFuture.completedFuture(objectMapper.writeValueAsString(") + .append(methodCall).append(")); } catch (Exception e) { throw new RuntimeException(e); }"); } return sb.toString(); @@ -576,10 +574,7 @@ private static String escapeJava(String s) { if (s == null) { return ""; } - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", + "\\t"); } } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 7fee76b1b..82a8a3aa2 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -8,12 +8,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.File; import java.net.URI; -import java.net.URL; import java.nio.file.Path; import java.security.CodeSource; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import javax.tools.Diagnostic; import javax.tools.DiagnosticCollector; @@ -445,24 +447,35 @@ private void collectGeneratedFiles(java.nio.file.Path dir, List files) { } private static String resolveClasspath() { - // Use the full system classpath so generated code can resolve all - // dependencies (e.g. Jackson ObjectMapper) during test compilation. + // Collect classpath entries from CodeSource of key classes needed for + // compiling both the source and the generated $$CopilotToolMeta code. + Set paths = new LinkedHashSet<>(); + + // Add system classpath entries (may include manifest-only jars) String systemCp = System.getProperty("java.class.path", ""); if (!systemCp.isEmpty()) { - return systemCp; + for (String p : systemCp.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + if (!p.isEmpty()) { + paths.add(p); + } + } } - CodeSource cs = CopilotTool.class.getProtectionDomain().getCodeSource(); - if (cs != null) { - URL location = cs.getLocation(); - if (location != null) { - try { - return Path.of(location.toURI()).toString(); - } catch (Exception e) { - return "."; + + // Also resolve CodeSource paths for key classes (SDK + Jackson + RPC types) + Class[] keyClasses = {CopilotTool.class, com.fasterxml.jackson.databind.ObjectMapper.class, + com.github.copilot.rpc.ToolDefinition.class}; + for (Class cls : keyClasses) { + try { + CodeSource cs = cls.getProtectionDomain().getCodeSource(); + if (cs != null && cs.getLocation() != null) { + paths.add(Path.of(cs.getLocation().toURI()).toString()); } + } catch (Exception e) { + // skip this class } } - return "."; + + return paths.isEmpty() ? "." : String.join(File.pathSeparator, paths); } private static JavaFileObject inMemorySource(String className, String code) { From b53f8384b444b04928871a16c88dd103e03635f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:37:53 +0000 Subject: [PATCH 23/29] fix: Add jackson-core and jackson-annotations to test classpath The generated 6342CopilotToolMeta code uses ObjectMapper which requires jackson-core (Versioned, JsonFactory) and jackson-annotations at compile time. Add these transitive dependencies to the key classes list so their CodeSource paths are included in the javac classpath. --- .../java/com/github/copilot/tool/CopilotToolProcessorTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 82a8a3aa2..da2f33d0d 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -463,6 +463,8 @@ private static String resolveClasspath() { // Also resolve CodeSource paths for key classes (SDK + Jackson + RPC types) Class[] keyClasses = {CopilotTool.class, com.fasterxml.jackson.databind.ObjectMapper.class, + com.fasterxml.jackson.core.JsonFactory.class, + com.fasterxml.jackson.annotation.JsonProperty.class, com.github.copilot.rpc.ToolDefinition.class}; for (Class cls : keyClasses) { try { From 1ac82f68722b82f0e522ff57a399675ab2ef1086 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:51:03 +0000 Subject: [PATCH 24/29] fix: Fix Spotless formatting for keyClasses array initializer --- .../java/com/github/copilot/tool/CopilotToolProcessorTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index da2f33d0d..02eb63511 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -463,8 +463,7 @@ private static String resolveClasspath() { // Also resolve CodeSource paths for key classes (SDK + Jackson + RPC types) Class[] keyClasses = {CopilotTool.class, com.fasterxml.jackson.databind.ObjectMapper.class, - com.fasterxml.jackson.core.JsonFactory.class, - com.fasterxml.jackson.annotation.JsonProperty.class, + com.fasterxml.jackson.core.JsonFactory.class, com.fasterxml.jackson.annotation.JsonProperty.class, com.github.copilot.rpc.ToolDefinition.class}; for (Class cls : keyClasses) { try { From c3c62917bd6a7987e32302c03af8d93f598e9bf2 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 21:51:35 -0400 Subject: [PATCH 25/29] fix(java): Pass ObjectMapper as parameter in generated $$CopilotToolMeta contract Address PR #1777 review comment (r3463252393): the generated $$CopilotToolMeta class was using `new ObjectMapper()`, which lacks the SDK Jackson configuration (JavaTimeModule, NON_NULL inclusion, lenient unknown-properties). This would break tool argument coercion and return serialization at runtime for java.time.* and other types. Instead of embedding a bare or configured ObjectMapper in the generated code, change the generated `definitions()` method signature from: definitions(MyTools instance) to: definitions(MyTools instance, ObjectMapper mapper) This establishes an internal contract: the caller (the future ToolDefinition.fromObject() in issue #1761) is responsible for supplying a properly configured mapper via reflective invocation. The generated code uses `mapper` for all convertValue() and writeValueAsString() calls. Benefits: - No DRY violation (mapper config stays canonical in JsonRpcClient) - No new public API exposing ObjectMapper - No package-visibility workarounds - Clean separation: generated code declares its needs, caller supplies Issue #1761 description has been updated to document this contract so the implementing agent knows to pass ObjectMapper as the second argument when reflectively invoking definitions(). --- .../github/copilot/tool/CopilotToolProcessor.java | 14 ++++++-------- .../copilot/tool/CopilotToolProcessorTest.java | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index c5cd890db..aca332b05 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -130,8 +130,6 @@ private void writeMetaClass(PrintWriter out, String packageName, String simpleCl out.println("final class " + metaClassName + " {"); out.println(); - out.println(" private static final ObjectMapper objectMapper = new ObjectMapper();"); - out.println(); // Helper method for adding description/default to schema maps if (needsWithMetaHelper(methods)) { @@ -147,7 +145,8 @@ private void writeMetaClass(PrintWriter out, String packageName, String simpleCl // definitions method out.println(" @SuppressWarnings({\"unchecked\", \"rawtypes\"})"); - out.println(" static List definitions(" + simpleClassName + " instance) {"); + out.println( + " static List definitions(" + simpleClassName + " instance, ObjectMapper mapper) {"); out.println(" return List.of("); for (int i = 0; i < methods.size(); i++) { @@ -332,14 +331,13 @@ private String generateLambdaBody(ExecutableElement method) { } else { // CompletableFuture -> serialize to JSON sb.append(" return ").append(methodCall) - .append(".thenApply(r -> { try { return (Object) objectMapper.writeValueAsString(r); }") + .append(".thenApply(r -> { try { return (Object) mapper.writeValueAsString(r); }") .append(" catch (Exception e) { throw new RuntimeException(e); } });"); } } else if (isStringType(returnType)) { sb.append(" return CompletableFuture.completedFuture(").append(methodCall).append(");"); } else { - sb.append( - " try { return CompletableFuture.completedFuture(objectMapper.writeValueAsString(") + sb.append(" try { return CompletableFuture.completedFuture(mapper.writeValueAsString(") .append(methodCall).append(")); } catch (Exception e) { throw new RuntimeException(e); }"); } @@ -374,7 +372,7 @@ private String generateArgExtractionFromMap(String paramName, TypeMirror type) { return "(Boolean) args.get(\"" + paramName + "\")"; } // Complex types: enums, records, POJOs - return "objectMapper.convertValue(args.get(\"" + paramName + "\"), " + qualifiedName + ".class)"; + return "mapper.convertValue(args.get(\"" + paramName + "\"), " + qualifiedName + ".class)"; } return "(Object) args.get(\"" + paramName + "\")"; } @@ -395,7 +393,7 @@ private String generateArgExtraction(String varExpr, TypeMirror type) { if ("java.lang.Boolean".equals(qualifiedName)) { return "(Boolean) " + varExpr; } - return "objectMapper.convertValue(" + varExpr + ", " + qualifiedName + ".class)"; + return "mapper.convertValue(" + varExpr + ", " + qualifiedName + ".class)"; } return "(Object) " + varExpr; } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 02eb63511..2d36d1321 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -202,7 +202,7 @@ public int doSomething(@Param("Input") String input) { CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.IntReturn", source))); assertNoErrors(result); String generated = result.getGeneratedSource("test.IntReturn$$CopilotToolMeta"); - assertTrue(generated.contains("objectMapper.writeValueAsString(instance.doSomething("), + assertTrue(generated.contains("mapper.writeValueAsString(instance.doSomething("), "Expected JSON serialization for int return type, got:\n" + generated); } From f5f7956b62030af30c840d718d5655ccc89c4dcf Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 22:06:35 -0400 Subject: [PATCH 26/29] fix(java): restrict single-param shortcut to records only Address review comment on PR #1777: the isRecordOrPojo heuristic incorrectly triggered for JDK container types (List, Map, etc.) when used as a single tool parameter. For example, a tool with parameter List would attempt to deserialize the entire arguments object as a List, failing at runtime. Replace the heuristic with a deterministic check: only Java records qualify for the getArgumentsAs() shortcut. Records are immutable data carriers with compiler-guaranteed component lists, making them safe for whole-object deserialization. POJOs and all other class types now fall through to the per-field extraction path, which always works correctly. Removed isSimpleType() helper which was only used by the old heuristic. --- .../github/copilot/tool/CopilotToolProcessor.java | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index aca332b05..b2030a713 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -286,7 +286,7 @@ private String generateLambdaBody(ExecutableElement method) { sb.append("Map args = invocation.getArguments();\n"); // Check if single-record-parameter shortcut applies - if (params.size() == 1 && isRecordOrPojo(params.get(0).asType())) { + if (params.size() == 1 && isRecord(params.get(0).asType())) { String typeName = getTypeString(params.get(0).asType()); String paramName = params.get(0).getSimpleName().toString(); sb.append(" ").append(typeName).append(" ").append(paramName) @@ -497,21 +497,12 @@ private String getTypeString(TypeMirror type) { return type.toString(); } - private boolean isRecordOrPojo(TypeMirror type) { + private boolean isRecord(TypeMirror type) { if (type.getKind() != TypeKind.DECLARED) { return false; } TypeElement typeElement = (TypeElement) ((DeclaredType) type).asElement(); - return typeElement.getKind() == ElementKind.RECORD || (typeElement.getKind() == ElementKind.CLASS - && !isSimpleType(typeElement.getQualifiedName().toString())); - } - - private boolean isSimpleType(String qualifiedName) { - return "java.lang.String".equals(qualifiedName) || "java.lang.Integer".equals(qualifiedName) - || "java.lang.Long".equals(qualifiedName) || "java.lang.Double".equals(qualifiedName) - || "java.lang.Float".equals(qualifiedName) || "java.lang.Boolean".equals(qualifiedName) - || "java.lang.Short".equals(qualifiedName) || "java.lang.Byte".equals(qualifiedName) - || "java.lang.Character".equals(qualifiedName) || "java.lang.Object".equals(qualifiedName); + return typeElement.getKind() == ElementKind.RECORD; } private boolean isCompletableFuture(TypeMirror type) { From ac2e24093c59a72c76a3ca9939b3d8ae883f2632 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 22:21:46 -0400 Subject: [PATCH 27/29] fix(java): emit typed default values in JSON Schema Address review comment on PR #1777: @Param(defaultValue=...) was always emitted as a JSON string in the generated schema's 'default' field, making numeric and boolean defaults the wrong type (e.g., "10" instead of 10, "true" instead of true). Changes: - withMeta helper: String defaultValue -> Object defaultValue - buildPropertySchema: reuse generateDefaultLiteral() to emit typed Java literals (int, boolean, etc.) instead of always quoting - Add test emitsTypedDefaultValuesInSchema verifying int -> 10, boolean -> true, String -> "hello" in generated code --- .../copilot/tool/CopilotToolProcessor.java | 8 ++--- .../tool/CopilotToolProcessorTest.java | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index b2030a713..471a8f9d6 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -134,7 +134,7 @@ private void writeMetaClass(PrintWriter out, String packageName, String simpleCl // Helper method for adding description/default to schema maps if (needsWithMetaHelper(methods)) { out.println( - " private static Map withMeta(Map base, String description, String defaultValue) {"); + " private static Map withMeta(Map base, String description, Object defaultValue) {"); out.println(" var result = new LinkedHashMap(base);"); out.println(" if (description != null) result.put(\"description\", description);"); out.println(" if (defaultValue != null) result.put(\"default\", defaultValue);"); @@ -238,7 +238,7 @@ private String generateSchemaWithParamMetadata(List p processingEnv.getElementUtils()); // Build property schema with description and default if present - String propertySchema = buildPropertySchema(typeSchema, paramAnnotation); + String propertySchema = buildPropertySchema(typeSchema, paramAnnotation, paramType); // Cast to Map via raw type for consistent Map.ofEntries typing propertyEntries.add("Map.entry(\"" + paramName + "\", (Map)(Map) " + propertySchema + ")"); @@ -255,7 +255,7 @@ private String generateSchemaWithParamMetadata(List p return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; } - private String buildPropertySchema(String typeSchema, Param paramAnnotation) { + private String buildPropertySchema(String typeSchema, Param paramAnnotation, TypeMirror paramType) { if (paramAnnotation == null) { return typeSchema; } @@ -272,7 +272,7 @@ private String buildPropertySchema(String typeSchema, Param paramAnnotation) { // Use the withMeta helper method in the generated class String descArg = hasDescription ? "\"" + escapeJava(desc) + "\"" : "null"; - String defaultArg = hasDefault ? "\"" + escapeJava(defaultValue) + "\"" : "null"; + String defaultArg = hasDefault ? generateDefaultLiteral(paramType, defaultValue) : "null"; return "withMeta(" + typeSchema + ", " + descArg + ", " + defaultArg + ")"; } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 2d36d1321..17ca94cc3 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; @@ -288,6 +289,41 @@ public String search( assertTrue(generated.contains("\"query\""), "Expected query property"); } + // ── Test: Typed default values in schema ──────────────────────────────────── + + @Test + void emitsTypedDefaultValuesInSchema() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class DefaultTools { + @CopilotTool("Tool with defaults") + public String doWork( + @Param(value = "Limit", required = false, defaultValue = "10") int limit, + @Param(value = "Enabled", required = false, defaultValue = "true") boolean enabled, + @Param(value = "Label", required = false, defaultValue = "hello") String label) { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.DefaultTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.DefaultTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for DefaultTools$$CopilotToolMeta"); + + // Numeric default should be an unquoted literal, not a string + assertTrue(generated.contains("withMeta(") && generated.contains(", 10)"), + "Expected numeric default 10 as typed literal, not string. Generated:\n" + generated); + // Boolean default should be an unquoted literal + assertTrue(generated.contains(", true)"), + "Expected boolean default true as typed literal, not string. Generated:\n" + generated); + // String default should remain a quoted string + assertTrue(generated.contains(", \"hello\")"), + "Expected string default \"hello\" as quoted string. Generated:\n" + generated); + } + // ── Test: package-private methods are allowed ─────────────────────────────── @Test From e939709164fdf33ee9ed5e632bd1bcef6bd441cc Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 22:26:05 -0400 Subject: [PATCH 28/29] fix(java): fix double 61059CopilotToolMeta suffix in test helper Address review comment on PR #1777: getGeneratedSource() fallback search appended 61059CopilotToolMeta to a simpleName that already contained it, producing MyTools$$CopilotToolMeta$$CopilotToolMeta. Simplify to just match on 'class '. --- .../com/github/copilot/tool/CopilotToolProcessorTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 17ca94cc3..b6851b376 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -559,9 +559,9 @@ String getGeneratedSource(String qualifiedName) { // fall through } // Also check in collected sources + String simpleName = qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); for (String source : generatedSources) { - if (source.contains(qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1) + "$$CopilotToolMeta") - || source.contains("class " + qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1))) { + if (source.contains("class " + simpleName)) { return source; } } From 887fef04a36638e55631875e46b036192fb0aa00 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 23 Jun 2026 22:31:57 -0400 Subject: [PATCH 29/29] fix(java): use record constructor for independent flag combination Address SDK Consistency Review on PR #1777: the if/else if chain in writeToolDefinition silently dropped combined annotation flags (e.g., overridesBuiltInTool + skipPermission + defer). All other SDKs support combining these flags simultaneously. Replace the factory method dispatch with a direct call to the ToolDefinition record constructor, which accepts all seven fields independently. Each flag is now emitted as its own argument: Boolean.TRUE or null for overridesBuiltInTool/skipPermission, ToolDefer.X or null for defer. Add test generatesCombinedFlags verifying all three flags appear in generated code when set together. --- .../copilot/tool/CopilotToolProcessor.java | 27 ++++------- .../tool/CopilotToolProcessorTest.java | 48 +++++++++++++++---- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 471a8f9d6..409c9802a 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -193,30 +193,21 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { // Generate invocation lambda String lambdaBody = generateLambdaBody(method); - // Determine factory method and arguments - out.print(" "); - if (overridesBuiltIn) { - out.println("ToolDefinition.createOverride("); - } else if (skipPermission) { - out.println("ToolDefinition.createSkipPermission("); - } else if (defer != com.github.copilot.rpc.ToolDefer.NONE) { - out.println("ToolDefinition.createWithDefer("); - } else { - out.println("ToolDefinition.create("); - } + // Use the record constructor directly so all flags apply independently + String overridesArg = overridesBuiltIn ? "Boolean.TRUE" : "null"; + String skipPermArg = skipPermission ? "Boolean.TRUE" : "null"; + String deferArg = defer != com.github.copilot.rpc.ToolDefer.NONE ? "ToolDefer." + defer.name() : "null"; + out.println(" new ToolDefinition("); out.println(" \"" + escapeJava(toolName) + "\","); out.println(" \"" + escapeJava(description) + "\","); out.println(" " + schemaSource + ","); out.println(" invocation -> {"); out.println(" " + lambdaBody); - out.println(" }"); - - // Add defer parameter if needed - if (defer != com.github.copilot.rpc.ToolDefer.NONE && !overridesBuiltIn && !skipPermission) { - out.println(" , ToolDefer." + defer.name()); - } - + out.println(" },"); + out.println(" " + overridesArg + ","); + out.println(" " + skipPermArg + ","); + out.println(" " + deferArg); out.print(" )"); } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index b6851b376..a92c1c3f7 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -381,8 +381,41 @@ public String grep(@Param("Query") String query) { CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.OverrideTools", source))); assertNoErrors(result); String generated = result.getGeneratedSource("test.OverrideTools$$CopilotToolMeta"); - assertTrue(generated.contains("ToolDefinition.createOverride("), - "Expected createOverride factory method, got:\n" + generated); + assertTrue(generated.contains("new ToolDefinition("), "Expected record constructor, got:\n" + generated); + assertTrue(generated.contains("Boolean.TRUE"), + "Expected Boolean.TRUE for overridesBuiltInTool, got:\n" + generated); + } + + // ── Test: Combined flags all apply independently ──────────────────────────── + + @Test + void generatesCombinedFlags() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.rpc.ToolDefer; + public class CombinedTools { + @CopilotTool(value = "Combined", overridesBuiltInTool = true, skipPermission = true, defer = ToolDefer.AUTO) + public String doAll() { + return "done"; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.CombinedTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.CombinedTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for CombinedTools$$CopilotToolMeta"); + assertTrue(generated.contains("new ToolDefinition("), "Expected record constructor, got:\n" + generated); + // All three flags must be present — not silently dropped + assertTrue(generated.contains("Boolean.TRUE"), + "Expected Boolean.TRUE for override/skipPermission, got:\n" + generated); + assertTrue(generated.contains("ToolDefer.AUTO"), "Expected ToolDefer.AUTO, got:\n" + generated); + // Count Boolean.TRUE occurrences — should be 2 (overridesBuiltInTool + + // skipPermission) + long boolCount = generated.lines().filter(l -> l.contains("Boolean.TRUE")).count(); + assertEquals(2, boolCount, + "Expected 2 Boolean.TRUE lines (overridesBuiltInTool + skipPermission), got:\n" + generated); } // ── Test: ToolDefer.NONE results in regular create ────────────────────────── @@ -404,10 +437,9 @@ public String doSomething() { CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.DeferNoneTools", source))); assertNoErrors(result); String generated = result.getGeneratedSource("test.DeferNoneTools$$CopilotToolMeta"); - assertTrue(generated.contains("ToolDefinition.create("), - "Expected create (not createWithDefer) for NONE, got:\n" + generated); - assertFalse(generated.contains("createWithDefer"), - "Should NOT use createWithDefer for NONE, got:\n" + generated); + assertTrue(generated.contains("new ToolDefinition("), + "Expected record constructor for NONE, got:\n" + generated); + assertFalse(generated.contains("ToolDefer."), "Should NOT reference ToolDefer for NONE, got:\n" + generated); } // ── Test: ToolDefer.AUTO results in createWithDefer ────────────────────────── @@ -429,8 +461,8 @@ public String doSomething() { CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.DeferAutoTools", source))); assertNoErrors(result); String generated = result.getGeneratedSource("test.DeferAutoTools$$CopilotToolMeta"); - assertTrue(generated.contains("ToolDefinition.createWithDefer("), - "Expected createWithDefer for AUTO, got:\n" + generated); + assertTrue(generated.contains("new ToolDefinition("), + "Expected record constructor for AUTO, got:\n" + generated); assertTrue(generated.contains("ToolDefer.AUTO"), "Expected ToolDefer.AUTO argument, got:\n" + generated); }