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 098c9faa91..4821c97869 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -237,8 +237,12 @@ private String generateSchemaWithParamMetadata(List p // 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()) { + // Determine if required (Optional* types are never required) + boolean isOptionalType = paramType.getKind() == TypeKind.DECLARED && Set + .of("java.util.Optional", "java.util.OptionalInt", "java.util.OptionalLong", + "java.util.OptionalDouble") + .contains(((TypeElement) ((DeclaredType) paramType).asElement()).getQualifiedName().toString()); + if (!isOptionalType && (paramAnnotation == null || paramAnnotation.required())) { requiredNames.add("\"" + paramName + "\""); } } @@ -284,7 +288,7 @@ private String generateLambdaBody(ExecutableElement method) { 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"); + .append(" = mapper.convertValue(args, ").append(typeName).append(".class);\n"); } else { for (VariableElement param : params) { String paramName = getParamName(param); diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java new file mode 100644 index 0000000000..703a6b0102 --- /dev/null +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java @@ -0,0 +1,49 @@ +// Hand-written test fixture mimicking CopilotToolProcessor output. +package com.github.copilot.e2e; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.ToolDefinition; +import com.github.copilot.tool.CopilotToolMetadataProvider; + +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public final class ErgonomicTestTools$$CopilotToolMeta implements CopilotToolMetadataProvider { + + private static Map withMeta(Map base, String description, Object defaultValue) { + var result = new LinkedHashMap(base); + if (description != null) + result.put("description", description); + if (defaultValue != null) + result.put("default", defaultValue); + return Collections.unmodifiableMap(result); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public List definitions(ErgonomicTestTools instance, ObjectMapper mapper) { + return List.of(new ToolDefinition("set_current_phase", "Sets the current phase of the agent", + Map.of("type", "object", "properties", + Map.ofEntries(Map.entry("phase", + (Map) (Map) withMeta(Map.of("type", "string"), + "The phase to transition to", null))), + "required", List.of("phase")), + invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + return CompletableFuture.completedFuture(instance.setCurrentPhase(phase)); + }, null, null, null), + new ToolDefinition( + "search_items", "Search for items by keyword", Map + .of("type", "object", "properties", + Map.ofEntries(Map.entry("keyword", + (Map) (Map) withMeta(Map.of("type", "string"), + "Search keyword", null))), + "required", List.of("keyword")), + invocation -> { + Map args = invocation.getArguments(); + String keyword = (String) args.get("keyword"); + return CompletableFuture.completedFuture(instance.searchItems(keyword)); + }, null, null, null)); + } +} diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools.java new file mode 100644 index 0000000000..1b5abba9fa --- /dev/null +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.e2e; + +import com.github.copilot.tool.CopilotTool; +import com.github.copilot.tool.Param; + +/** + * Tool fixture for the ergonomic {@code @CopilotTool} E2E integration test. + * + *

+ * This class exercises the annotation-based tool definition API, producing + * identical wire-level tool schemas to the low-level + * {@code ToolDefinition.create()} API. + */ +class ErgonomicTestTools { + + String currentPhase; + + @CopilotTool("Sets the current phase of the agent") + public String setCurrentPhase(@Param("The phase to transition to") String phase) { + currentPhase = phase; + return "Phase set to " + phase; + } + + @CopilotTool("Search for items by keyword") + public String searchItems(@Param("Search keyword") String keyword) { + return "Found: item_alpha, item_beta"; + } +} diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicToolDefinitionIT.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicToolDefinitionIT.java new file mode 100644 index 0000000000..c74e945444 --- /dev/null +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicToolDefinitionIT.java @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.e2e; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.CopilotClient; +import com.github.copilot.CopilotSession; +import com.github.copilot.E2ETestContext; +import com.github.copilot.generated.AssistantMessageEvent; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.ToolDefinition; +import com.github.copilot.rpc.ToolSet; + +/** + * Failsafe integration test for the ergonomic {@code @CopilotTool} + + * {@code ToolDefinition.fromObject()} API. + * + *

+ * This test proves that the ergonomic annotation-based API produces identical + * wire behavior to the low-level {@code ToolDefinition.create()} API tested in + * {@code LowLevelToolDefinitionIT}. + * + * @see Snapshot: tools/ergonomic_tool_definition + */ +class ErgonomicToolDefinitionIT { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void ergonomicToolDefinition() throws Exception { + ctx.configureForTest("tools", "ergonomic_tool_definition"); + + ErgonomicTestTools tools = new ErgonomicTestTools(); + List toolDefs = ToolDefinition.fromObject(tools); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setAvailableTools(new ToolSet().addCustom("*").addBuiltIn("web_fetch")).setTools(toolDefs)) + .get(30, TimeUnit.SECONDS); + + try { + AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt( + "First, set the current phase to 'analyzing'. Then search for items with keyword 'copilot'. Report the phase and search results."), + 60_000).get(90, TimeUnit.SECONDS); + + assertNotNull(response, "Expected a response from the assistant"); + String content = response.getData().content().toLowerCase(); + assertTrue(content.contains("analyzing"), + "Response should contain the updated phase: " + response.getData().content()); + assertTrue(content.contains("item_alpha") || content.contains("item_beta"), + "Response should contain search results: " + response.getData().content()); + assertTrue("analyzing".equals(tools.currentPhase), + "Expected currentPhase to be 'analyzing' but was: " + tools.currentPhase); + } finally { + session.close(); + } + } + } +} diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index bea1aad194..74d223e632 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -34,8 +34,9 @@ /** * End-to-end tests for {@link ToolDefinition#fromObject(Object)}. *

- * The annotation processor generates {@code $$CopilotToolMeta} companion - * classes for the fixture classes during test compilation. + * These tests use hand-written {@code $$CopilotToolMeta} companion classes + * under {@code com.github.copilot.rpc.fixtures} that mimic + * {@link com.github.copilot.tool.CopilotToolProcessor} output. */ @AllowCopilotExperimental class ToolDefinitionFromObjectTest { diff --git a/test/snapshots/tools/ergonomic_tool_definition.yaml b/test/snapshots/tools/ergonomic_tool_definition.yaml new file mode 100644 index 0000000000..03cb0748a2 --- /dev/null +++ b/test/snapshots/tools/ergonomic_tool_definition.yaml @@ -0,0 +1,32 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: First, set the current phase to 'analyzing'. Then search for items with keyword 'copilot'. Report the phase and + search results. + - role: assistant + content: I'll set the phase and run the search now. + tool_calls: + - id: toolcall_0 + type: function + function: + name: set_current_phase + arguments: '{"phase":"analyzing"}' + - id: toolcall_1 + type: function + function: + name: search_items + arguments: '{"keyword":"copilot"}' + - role: tool + tool_call_id: toolcall_0 + content: Phase set to analyzing + - role: tool + tool_call_id: toolcall_1 + content: "Found: item_alpha, item_beta" + - role: assistant + content: |- + Current phase: analyzing + Search results: item_alpha, item_beta