diff --git a/think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java b/think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java index 4ccf16a..2334648 100644 --- a/think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java +++ b/think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java @@ -47,7 +47,7 @@ public class ThinkConsumer implements AutoCloseable { Be concise and helpful. When using tools, explain what you're doing and why."""; - private static final String SYSTEM_PROMPT_TEMPLATE = loadSystemPromptTemplate() + "\n\n%s"; + private static final String SYSTEM_PROMPT_BASE = loadSystemPromptTemplate(); private static String loadSystemPromptTemplate() { return loadSystemPromptFromFile(AppConfig.SYSTEM_PROMPT_FILE); @@ -353,15 +353,19 @@ private void produceResponse(String sessionId, ThinkResponse response) throws Ex * Builds the system prompt, injecting memoir context if available. */ static String buildSystemPrompt(String memoirContext) { - StringBuilder extra = new StringBuilder(); + return buildSystemPrompt(SYSTEM_PROMPT_BASE, memoirContext); + } + + static String buildSystemPrompt(String basePrompt, String memoirContext) { + StringBuilder out = new StringBuilder(basePrompt).append("\n\n"); if (memoirContext != null && !memoirContext.isBlank()) { - extra.append("\n\nUser memoir (known facts about this user from previous sessions):\n"); - extra.append(memoirContext); - extra.append("\n\nUse the memoir to personalize your responses."); + out.append("\n\nUser memoir (known facts about this user from previous sessions):\n"); + out.append(memoirContext); + out.append("\n\nUse the memoir to personalize your responses."); } - return String.format(SYSTEM_PROMPT_TEMPLATE, extra.toString()); + return out.toString(); } /** diff --git a/think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java b/think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java index c1f7719..0446258 100644 --- a/think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java +++ b/think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java @@ -36,4 +36,34 @@ void missingFile_throws() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("SYSTEM_PROMPT_FILE not found"); } + + @Test + @DisplayName("System prompt containing special characters is preserved literally (issue #23)") + void specialChars_inPrompt_doesNotThrow() { + String basePrompt = "Apply a 10% discount when asked."; + + String withoutMemoir = ThinkConsumer.buildSystemPrompt(basePrompt, null); + assertThat(withoutMemoir).contains("10% discount"); + + String withMemoir = ThinkConsumer.buildSystemPrompt(basePrompt, "user likes 50% off coupons"); + assertThat(withMemoir) + .contains("10% discount") + .contains("50% off coupons"); + + // Sequences that String.format would interpret as conversions/specifiers + // must survive unchanged now that the prompt is concatenated, not formatted. + assertThat(ThinkConsumer.buildSystemPrompt("Print %s and %d and %n literally.", null)) + .contains("%s and %d and %n literally."); + assertThat(ThinkConsumer.buildSystemPrompt("A 100%% bonus", "memoir with %s token")) + .contains("100%% bonus") + .contains("memoir with %s token"); + + // Other characters that are easy to mishandle in string plumbing. + assertThat(ThinkConsumer.buildSystemPrompt("Use a backslash \\ and a dollar $ sign.", null)) + .contains("backslash \\ and a dollar $ sign."); + assertThat(ThinkConsumer.buildSystemPrompt("Braces {0} {1} and brackets [x] stay.", null)) + .contains("Braces {0} {1} and brackets [x] stay."); + assertThat(ThinkConsumer.buildSystemPrompt("Quotes \"q\" 'a' and emoji 🚀 stay.", null)) + .contains("Quotes \"q\" 'a' and emoji 🚀 stay."); + } }