From d5505700a8c1f43969ed2f02f82302ad38c4cec3 Mon Sep 17 00:00:00 2001 From: Yash Mewada Date: Tue, 5 Aug 2025 18:58:03 -0700 Subject: [PATCH] Add validation for prompt name uniqueness across the MCP Server as well as in a single smithy model at build time. --- .../java/example/server/mcp/main.smithy | 2 +- .../smithy/java/mcp/server/McpServer.java | 4 +- .../smithy/java/mcp/server/PromptLoader.java | 20 +- .../smithy/java/mcp/server/McpServerTest.java | 89 ++++++ .../java/mcp/server/PromptLoaderTest.java | 302 ++++++++++++++++++ smithy-ai-traits/model/prompts.smithy | 8 +- .../smithy/ai/PromptUniquenessValidator.java | 83 +++++ ...e.amazon.smithy.model.validation.Validator | 1 + .../ai/PromptUniquenessValidatorTest.java | 112 +++++++ .../resources/cross-service-unique.smithy | 26 ++ .../test/resources/duplicate-prompts.smithy | 30 ++ .../resources/service-level-duplicates.smithy | 20 ++ .../src/test/resources/unique-prompts.smithy | 31 ++ 13 files changed, 721 insertions(+), 7 deletions(-) create mode 100644 smithy-ai-traits/src/main/java/software/amazon/smithy/ai/PromptUniquenessValidator.java create mode 100644 smithy-ai-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator create mode 100644 smithy-ai-traits/src/test/java/software/amazon/smithy/ai/PromptUniquenessValidatorTest.java create mode 100644 smithy-ai-traits/src/test/resources/cross-service-unique.smithy create mode 100644 smithy-ai-traits/src/test/resources/duplicate-prompts.smithy create mode 100644 smithy-ai-traits/src/test/resources/service-level-duplicates.smithy create mode 100644 smithy-ai-traits/src/test/resources/unique-prompts.smithy diff --git a/examples/mcp-server/src/main/resources/software/amazon/smithy/java/example/server/mcp/main.smithy b/examples/mcp-server/src/main/resources/software/amazon/smithy/java/example/server/mcp/main.smithy index 4d98ec546..c3679d94b 100644 --- a/examples/mcp-server/src/main/resources/software/amazon/smithy/java/example/server/mcp/main.smithy +++ b/examples/mcp-server/src/main/resources/software/amazon/smithy/java/example/server/mcp/main.smithy @@ -19,7 +19,7 @@ use smithy.ai#prompts arguments: GetCodingStatisticsInput preferWhen: "User wants to analyze developer productivity, review coding activity, or understand technology usage patterns" } - employee_lookup: { + Test_employee: { description: "General employee lookup and information retrieval service" template: "This service provides employee information including personal details and coding statistics. Use get_employee_info for basic details or get_coding_stats for development metrics." preferWhen: "User needs any employee-related information or wants to understand available employee data" diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java index a5cef4f09..c24fb5c3d 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java @@ -5,6 +5,8 @@ package software.amazon.smithy.java.mcp.server; +import static software.amazon.smithy.java.mcp.server.PromptLoader.normalize; + import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; @@ -146,7 +148,7 @@ private void handleRequest(JsonRpcRequest req) { var promptName = req.getParams().getMember("name").asString(); var promptArguments = req.getParams().getMember("arguments"); - var prompt = prompts.get(promptName); + var prompt = prompts.get(normalize(promptName)); if (prompt == null) { internalError(req, new RuntimeException("Prompt not found: " + promptName)); diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/PromptLoader.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/PromptLoader.java index 6267a663a..e3dbbaa74 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/PromptLoader.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/PromptLoader.java @@ -54,7 +54,7 @@ public static Map loadPrompts(Collection services) { }); for (Map.Entry entry : promptDefinitions.entrySet()) { - var promptName = entry.getKey().toLowerCase(); + var promptName = entry.getKey(); var promptTemplateDefinition = entry.getValue(); var templateString = promptTemplateDefinition.getTemplate(); @@ -76,8 +76,20 @@ public static Map loadPrompts(Collection services) { : List.of()) .build(); + var normalizedName = normalize(promptName); + + if (prompts.containsKey(normalizedName)) { + var existingPrompt = prompts.get(normalizedName); + throw new RuntimeException(String.format( + "Duplicate normalized prompt name '%s' found. " + + "Original name '%s' conflicts with previously registered name '%s'.", + normalizedName, + promptName, + existingPrompt.promptInfo().getName())); + } + prompts.put( - promptName, + normalizedName, new Prompt(promptInfo, finalTemplateString)); } } @@ -118,4 +130,8 @@ public static List convertArgumentShapeToPromptArgument(Schema a return promptArguments; } + + public static String normalize(String promptName) { + return promptName.toLowerCase(); + } } diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java index de203a4fa..4167c6582 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java @@ -395,6 +395,16 @@ void testPromptsList() { assertEquals("search_users", servicePromptMap.get("name").asString()); assertEquals("Test Template", servicePromptMap.get("description").asString()); assertTrue(servicePromptMap.get("arguments").asList().isEmpty()); + + var promptNames = prompts.stream() + .map(p -> p.asStringMap().get("name").asString()) + .toList(); + assertTrue(promptNames.contains("search_users")); + assertTrue(promptNames.contains("perform_operation")); + + for (String name : promptNames) { + assertEquals(name.toLowerCase(), name, "Prompt name should be normalized to lowercase: " + name); + } } @Test @@ -430,6 +440,76 @@ void testPromptsGetWithValidPrompt() { assertEquals("Search for if many results expected.", content.get("text").asString()); } + @Test + void testPromptsGetWithDifferentCasing() { + server = McpServer.builder() + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + // Test with uppercase prompt name + write("prompts/get", + Document.of(Map.of( + "name", + Document.of("SEARCH_USERS")))); + var response = read(); + var result = response.getResult().asStringMap(); + + assertEquals("Test Template", result.get("description").asString()); + var messages = result.get("messages").asList(); + assertEquals(1, messages.size()); + + var message = messages.get(0).asStringMap(); + assertEquals("user", message.get("role").asString()); + var content = message.get("content").asStringMap(); + assertEquals("text", content.get("type").asString()); + assertEquals("Search for if many results expected.", content.get("text").asString()); + + // Test with mixed case prompt name + write("prompts/get", + Document.of(Map.of( + "name", + Document.of("Search_Users")))); + response = read(); + result = response.getResult().asStringMap(); + + assertEquals("Test Template", result.get("description").asString()); + messages = result.get("messages").asList(); + assertEquals(1, messages.size()); + + message = messages.get(0).asStringMap(); + assertEquals("user", message.get("role").asString()); + content = message.get("content").asStringMap(); + assertEquals("text", content.get("type").asString()); + assertEquals("Search for if many results expected.", content.get("text").asString()); + + // Test with perform_operation prompt in different case + write("prompts/get", + Document.of(Map.of( + "name", + Document.of("PERFORM_OPERATION")))); + response = read(); + result = response.getResult().asStringMap(); + + assertEquals("perform operation", result.get("description").asString()); + messages = result.get("messages").asList(); + assertEquals(1, messages.size()); + + message = messages.get(0).asStringMap(); + assertEquals("user", message.get("role").asString()); + content = message.get("content").asStringMap(); + assertEquals("text", content.get("type").asString()); + assertEquals("use tool TestOperation with some information.", content.get("text").asString()); + } + @Test void testPromptsGetWithInvalidPrompt() { server = McpServer.builder() @@ -452,6 +532,15 @@ void testPromptsGetWithInvalidPrompt() { var response = read(); assertNotNull(response.getError()); assertTrue(response.getError().getMessage().contains("Prompt not found: nonexistent_prompt")); + + // Test with invalid prompt in different case - should still fail + write("prompts/get", + Document.of(Map.of( + "name", + Document.of("NONEXISTENT_PROMPT")))); + response = read(); + assertNotNull(response.getError()); + assertTrue(response.getError().getMessage().contains("Prompt not found: NONEXISTENT_PROMPT")); } @Test diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/PromptLoaderTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/PromptLoaderTest.java index 26f12f8e5..a20e44f69 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/PromptLoaderTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/PromptLoaderTest.java @@ -5,10 +5,16 @@ package software.amazon.smithy.java.mcp.server; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.server.ProxyService; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; class PromptLoaderTest { @@ -17,4 +23,300 @@ public void testLoadPromptsWithNoServices() { var prompts = PromptLoader.loadPrompts(Collections.emptyList()); assertTrue(prompts.isEmpty()); } + + @Test + public void testLoadPromptsWithUniqueNormalizedNames() { + var model = Model.assembler() + .addUnparsedModel("test-unique.smithy", UNIQUE_PROMPTS_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var service = ProxyService.builder() + .service(ShapeId.from("smithy.test.unique#TestService")) + .proxyEndpoint("http://localhost") + .model(model) + .build(); + + var prompts = PromptLoader.loadPrompts(List.of(service)); + + // Should have 3 prompts with unique normalized names + assertEquals(3, prompts.size()); + assertTrue(prompts.containsKey("getuser")); + assertTrue(prompts.containsKey("createuser")); + assertTrue(prompts.containsKey("deleteuser")); + } + + @Test + public void testLoadPromptsWithDuplicateNormalizedNamesFromDifferentServices() { + var model1 = Model.assembler() + .addUnparsedModel("test-service1.smithy", SERVICE1_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var model2 = Model.assembler() + .addUnparsedModel("test-service2.smithy", SERVICE2_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var service1 = ProxyService.builder() + .service(ShapeId.from("smithy.test.service1#TestService1")) + .proxyEndpoint("http://localhost") + .model(model1) + .build(); + + var service2 = ProxyService.builder() + .service(ShapeId.from("smithy.test.service2#TestService2")) + .proxyEndpoint("http://localhost") + .model(model2) + .build(); + + var exception = assertThrows(RuntimeException.class, () -> { + PromptLoader.loadPrompts(List.of(service1, service2)); + }); + + String message = exception.getMessage(); + assertTrue(message.contains("Duplicate normalized prompt name 'getuser' found")); + assertTrue(message.contains("Original name 'getuser' conflicts with previously registered name 'GetUser'")); + } + + @Test + public void testLoadPromptsWithCaseSensitivityVariationsAcrossServices() { + var model1 = Model.assembler() + .addUnparsedModel("test-case1.smithy", CASE_SERVICE1_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var model2 = Model.assembler() + .addUnparsedModel("test-case2.smithy", CASE_SERVICE2_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var service1 = ProxyService.builder() + .service(ShapeId.from("smithy.test.case1#TestService1")) + .proxyEndpoint("http://localhost") + .model(model1) + .build(); + + var service2 = ProxyService.builder() + .service(ShapeId.from("smithy.test.case2#TestService2")) + .proxyEndpoint("http://localhost") + .model(model2) + .build(); + + var exception = assertThrows(RuntimeException.class, () -> { + PromptLoader.loadPrompts(List.of(service1, service2)); + }); + + String message = exception.getMessage(); + assertTrue(message.contains("Duplicate normalized prompt name 'getuser' found")); + assertTrue(message.contains("Original name 'GETUSER' conflicts with previously registered name 'GetUser'")); + } + + @Test + public void testLoadPromptsWithSingleCharacterNamesAcrossServices() { + var model1 = Model.assembler() + .addUnparsedModel("test-single1.smithy", SINGLE_SERVICE1_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var model2 = Model.assembler() + .addUnparsedModel("test-single2.smithy", SINGLE_SERVICE2_MODEL) + .discoverModels() + .assemble() + .unwrap(); + + var service1 = ProxyService.builder() + .service(ShapeId.from("smithy.test.single1#TestService1")) + .proxyEndpoint("http://localhost") + .model(model1) + .build(); + + var service2 = ProxyService.builder() + .service(ShapeId.from("smithy.test.single2#TestService2")) + .proxyEndpoint("http://localhost") + .model(model2) + .build(); + + var exception = assertThrows(RuntimeException.class, () -> { + PromptLoader.loadPrompts(List.of(service1, service2)); + }); + + String message = exception.getMessage(); + assertTrue(message.contains("Duplicate normalized prompt name 'a' found")); + assertTrue(message.contains("Original name 'a' conflicts with previously registered name 'A'")); + } + + @Test + public void testNormalizeMethod() { + // Test the normalize method directly + assertEquals("getuser", PromptLoader.normalize("GetUser")); + assertEquals("getuser", PromptLoader.normalize("GETUSER")); + assertEquals("getuser", PromptLoader.normalize("getuser")); + assertEquals("getuser", PromptLoader.normalize("getUser")); + assertEquals("a", PromptLoader.normalize("A")); + assertEquals("a", PromptLoader.normalize("a")); + assertEquals("get-user", PromptLoader.normalize("Get-User")); + assertEquals("get_user", PromptLoader.normalize("Get_User")); + } + + // Test model with unique normalized prompt names + private static final String UNIQUE_PROMPTS_MODEL = + """ + $version: "2" + + namespace smithy.test.unique + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + GetUser: { description: "Get a user", template: "Get user information" }, + CreateUser: { description: "Create a user", template: "Create new user" }, + DeleteUser: { description: "Delete a user", template: "Delete existing user" } + }) + service TestService { + operations: [] + } + """; + + // First service with GetUser prompt + private static final String SERVICE1_MODEL = + """ + $version: "2" + + namespace smithy.test.service1 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + GetUser: { description: "Get a user", template: "Get user information" } + }) + service TestService1 { + operations: [] + } + """; + + // Second service with getuser prompt (different case, same normalized name) + private static final String SERVICE2_MODEL = + """ + $version: "2" + + namespace smithy.test.service2 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + getuser: { description: "get a user", template: "get user information" } + }) + service TestService2 { + operations: [] + } + """; + + // First service with GetUser prompt for case sensitivity test + private static final String CASE_SERVICE1_MODEL = + """ + $version: "2" + + namespace smithy.test.case1 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + GetUser: { description: "Get a user", template: "Get user information" } + }) + service TestService1 { + operations: [] + } + """; + + // Second service with GETUSER prompt for case sensitivity test + private static final String CASE_SERVICE2_MODEL = + """ + $version: "2" + + namespace smithy.test.case2 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + GETUSER: { description: "GET A USER", template: "GET USER INFORMATION" } + }) + service TestService2 { + operations: [] + } + """; + + // First service with A prompt for single character test + private static final String SINGLE_SERVICE1_MODEL = + """ + $version: "2" + + namespace smithy.test.single1 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + A: { description: "Prompt A", template: "Template A" } + }) + service TestService1 { + operations: [] + } + """; + + // Second service with a prompt for single character test + private static final String SINGLE_SERVICE2_MODEL = + """ + $version: "2" + + namespace smithy.test.single2 + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + a: { description: "prompt a", template: "template a" } + }) + service TestService2 { + operations: [] + } + """; + + // Test model with special characters in prompt names + private static final String SPECIAL_CHARS_MODEL = + """ + $version: "2" + + namespace smithy.test.special + + use smithy.ai#prompts + use aws.protocols#awsJson1_0 + + @awsJson1_0 + @prompts({ + "Get-User": { description: "Get a user", template: "Get user information" }, + "Get_Item": { description: "Get an item", template: "Get item information" } + }) + service TestService { + operations: [] + } + """; } diff --git a/smithy-ai-traits/model/prompts.smithy b/smithy-ai-traits/model/prompts.smithy index d3f64fe6c..e2f9fd81e 100644 --- a/smithy-ai-traits/model/prompts.smithy +++ b/smithy-ai-traits/model/prompts.smithy @@ -2,16 +2,18 @@ $version: "2" namespace smithy.ai -// Prompt template trait - applied at operation level to provide guidance to LLMs -@trait(selector: ":is(service, resource, operation)") +@trait(selector: ":is(service, operation)") map prompts { /// Name of the prompt template - key: String + key: PromptName /// Definition of the prompt template value: PromptTemplateDefinition } +@pattern("^[a-zA-Z0-9]+(?:_[a-zA-Z0-9]+)*$") +string PromptName + /// Defines the structure of the prompt @private structure PromptTemplateDefinition { diff --git a/smithy-ai-traits/src/main/java/software/amazon/smithy/ai/PromptUniquenessValidator.java b/smithy-ai-traits/src/main/java/software/amazon/smithy/ai/PromptUniquenessValidator.java new file mode 100644 index 000000000..4c79aa8ab --- /dev/null +++ b/smithy-ai-traits/src/main/java/software/amazon/smithy/ai/PromptUniquenessValidator.java @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.ai; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; + +/** + * Validates that prompt names are unique within each service when compared case-insensitively. + * This validator checks both service-level and operation-level prompts to ensure no conflicts. + * + * For example, "search_users" and "Search_Users" would be considered conflicting prompt names + * because they are identical when compared case-insensitively. + */ +public final class PromptUniquenessValidator extends AbstractValidator { + + @Override + public List validate(Model model) { + List events = new ArrayList<>(); + + for (ServiceShape service : model.getServiceShapes()) { + validatePrompts(service, model, events); + } + + return events; + } + + private void validatePrompts(ServiceShape service, Model model, List events) { + // Track prompt names to detect case-insensitive conflicts + Map seenPromptNames = new HashMap<>(); + + service.getTrait(PromptsTrait.class).ifPresent(promptsTrait -> { + for (String promptName : promptsTrait.getValues().keySet()) { + checkPromptUniqueness(promptName, service, seenPromptNames, events); + } + }); + + for (OperationShape operation : service.getOperations() + .stream() + .map(shapeId -> model.expectShape(shapeId, OperationShape.class)) + .toList()) { + + operation.getTrait(PromptsTrait.class).ifPresent(promptsTrait -> { + for (String promptName : promptsTrait.getValues().keySet()) { + checkPromptUniqueness(promptName, operation, seenPromptNames, events); + } + }); + } + } + + private void checkPromptUniqueness( + String promptName, + Shape shape, + Map seenPromptNames, + List events + ) { + String normalizedName = promptName.toLowerCase(); + + if (seenPromptNames.containsKey(normalizedName)) { + Shape conflictingShape = seenPromptNames.get(normalizedName); + events.add(error(shape, + String.format( + "Duplicate prompt name detected: '%s' conflicts with an existing prompt " + + "defined on %s when compared case-insensitively. Prompt names must be unique " + + "within a service regardless of case.", + promptName, + conflictingShape.getId()))); + } else { + seenPromptNames.put(normalizedName, shape); + } + } +} diff --git a/smithy-ai-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-ai-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator new file mode 100644 index 000000000..3879bd439 --- /dev/null +++ b/smithy-ai-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -0,0 +1 @@ +software.amazon.smithy.ai.PromptUniquenessValidator diff --git a/smithy-ai-traits/src/test/java/software/amazon/smithy/ai/PromptUniquenessValidatorTest.java b/smithy-ai-traits/src/test/java/software/amazon/smithy/ai/PromptUniquenessValidatorTest.java new file mode 100644 index 000000000..7926ed4b8 --- /dev/null +++ b/smithy-ai-traits/src/test/java/software/amazon/smithy/ai/PromptUniquenessValidatorTest.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.ai; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.validation.ValidationEvent; + +public class PromptUniquenessValidatorTest { + + /** + * Test case record for prompt uniqueness validation scenarios. + * + * @param testName descriptive name for the test scenario + * @param resourcePath path to the Smithy model file + * @param expectedErrorCount number of validation errors expected + * @param expectedErrorContent specific content to verify in error messages (null if no errors expected) + */ + public record ValidationTestCase( + String testName, + String resourcePath, + int expectedErrorCount, + String expectedErrorContent) { + @Override + public String toString() { + return testName; + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideValidationTestCases") + @DisplayName("Prompt uniqueness validation scenarios") + public void testPromptUniquenessValidation(ValidationTestCase testCase) { + var result = assembleModelFromResource(testCase.resourcePath()); + List duplicateEvents = filterDuplicatePromptEvents(result.getValidationEvents()); + + assertEquals(testCase.expectedErrorCount(), + duplicateEvents.size(), + String.format("Test '%s': Expected %d validation errors", + testCase.testName(), + testCase.expectedErrorCount())); + + if (testCase.expectedErrorCount() > 0) { + ValidationEvent event = duplicateEvents.get(0); + assertTrue(event.getMessage().contains("Duplicate prompt name detected"), + String.format("Test '%s': Should contain duplicate prompt error message", testCase.testName())); + + if (testCase.expectedErrorContent() != null) { + assertTrue(event.getMessage().contains(testCase.expectedErrorContent()), + String.format("Test '%s': Should contain expected error content: %s", + testCase.testName(), + testCase.expectedErrorContent())); + } + + assertTrue(event.getMessage().contains("case-insensitively"), + String.format("Test '%s': Should mention case-insensitive comparison", testCase.testName())); + } + } + + /** + * Provides test cases for prompt uniqueness validation scenarios. + * Each test case includes: test name, resource path, expected error count, and expected error content. + */ + private static Stream provideValidationTestCases() { + return Stream.of( + new ValidationTestCase( + "No duplicate prompts", + "/unique-prompts.smithy", + 0, + null), + new ValidationTestCase( + "Duplicate prompts within service", + "/service-level-duplicates.smithy", + 1, + "Search_Users"), + new ValidationTestCase( + "Duplicate prompts between service and operation", + "/duplicate-prompts.smithy", + 1, + "Search_Users"), + new ValidationTestCase( + "Same prompt names across different services (should be allowed)", + "/cross-service-unique.smithy", + 0, + null)); + } + + private ValidatedResult assembleModelFromResource(String resourcePath) { + return Model.assembler() + .addImport(Objects.requireNonNull(getClass().getResource(resourcePath))) + .discoverModels(getClass().getClassLoader()) + .assemble(); + } + + private List filterDuplicatePromptEvents(List events) { + return events.stream() + .filter(event -> event.getMessage().contains("Duplicate prompt name detected")) + .toList(); + } +} diff --git a/smithy-ai-traits/src/test/resources/cross-service-unique.smithy b/smithy-ai-traits/src/test/resources/cross-service-unique.smithy new file mode 100644 index 000000000..e9b4dfe14 --- /dev/null +++ b/smithy-ai-traits/src/test/resources/cross-service-unique.smithy @@ -0,0 +1,26 @@ +$version: "2.0" + +namespace com.example.test.cross + +use smithy.ai#prompts + +// Test model for validating that same prompt names are allowed across different services +@prompts( + "search_users": { + template: "Search for users in ServiceOne" + description: "ServiceOne search template" + } +) +service ServiceOne { + operations: [] +} + +@prompts( + "search_users": { + template: "Search for users in ServiceTwo" + description: "ServiceTwo search template" + } +) +service ServiceTwo { + operations: [] +} diff --git a/smithy-ai-traits/src/test/resources/duplicate-prompts.smithy b/smithy-ai-traits/src/test/resources/duplicate-prompts.smithy new file mode 100644 index 000000000..784ab43b4 --- /dev/null +++ b/smithy-ai-traits/src/test/resources/duplicate-prompts.smithy @@ -0,0 +1,30 @@ +$version: "2.0" + +namespace com.example.test + +use smithy.ai#prompts + +@prompts( + "search_users": { + template: "Search for users in the system" + description: "Service-level search template" + } + "list_items": { + template: "List all items" + description: "List template" + } +) +service TestService { + operations: [SearchOperation] +} + +@prompts( + "Search_Users": { + template: "Search for users from operation" + description: "Operation-level search template (conflicts with service-level)" + } +) +operation SearchOperation { + input := {} + output := {} +} diff --git a/smithy-ai-traits/src/test/resources/service-level-duplicates.smithy b/smithy-ai-traits/src/test/resources/service-level-duplicates.smithy new file mode 100644 index 000000000..f04b54e28 --- /dev/null +++ b/smithy-ai-traits/src/test/resources/service-level-duplicates.smithy @@ -0,0 +1,20 @@ +$version: "2.0" + +namespace com.example.test.service + +use smithy.ai#prompts + +// Test model for validating service-level prompt name conflicts +@prompts( + "search_users": { + template: "Search for users in the system" + description: "Service-level search template" + } + "Search_Users": { + template: "DUPLICATE: Search for users again (intentional case conflict)" + description: "Duplicate search template with different case for testing validation" + } +) +service TestService { + operations: [] +} diff --git a/smithy-ai-traits/src/test/resources/unique-prompts.smithy b/smithy-ai-traits/src/test/resources/unique-prompts.smithy new file mode 100644 index 000000000..e187503d6 --- /dev/null +++ b/smithy-ai-traits/src/test/resources/unique-prompts.smithy @@ -0,0 +1,31 @@ +$version: "2.0" + +namespace com.example.test.unique + +use smithy.ai#prompts + +// Test model for validating that unique prompt names do not trigger validation errors +@prompts( + "search_users": { + template: "Search for users in the system" + description: "Service-level search template" + } + "list_items": { + template: "List all items" + description: "List template" + } +) +service TestService { + operations: [SearchOperation] +} + +@prompts( + "get_details": { + template: "Get detailed information" + description: "Operation-level details template" + } +) +operation SearchOperation { + input := {} + output := {} +}