fix: Match front and backend prompt variable behavior (#8522)

* fix handling of braces in front and back end

* add tests

* ruff check fix

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes

* address pr comments and switches to use formatter

* remove code fence that isn't working

* comment

* [autofix.ci] apply automated fixes

* text fix

* style fix

* test fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Mike Fortman 2025-06-18 09:24:39 -05:00 committed by GitHub
commit ddc17d4c77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 261 additions and 48 deletions

View file

@ -26,21 +26,33 @@ export default function PromptAreaComponent({
readonly = false,
}: InputProps<string, PromptAreaComponentType>): JSX.Element {
const coloredContent = (typeof value === "string" ? value : "")
// escape HTML first
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(regexHighlight, (match, p1, p2) => {
// Decide which group was matched. If p1 is not undefined, do nothing
// we don't want to change the text. If p2 is not undefined, then we
// have a variable, so we should highlight it.
// ! This will not work with multiline or indented json yet
if (p1 !== undefined) {
return match;
} else if (p2 !== undefined) {
return `<span class="chat-message-highlight">{${p2}}</span>`;
}
// highlight variables
.replace(regexHighlight, (match, codeFence, openRun, varName, closeRun) => {
// 1) Leave ```code``` blocks untouched
if (codeFence) return match;
return match;
// 2) Balanced & odd-length brace runs mean “real variable”
const lenOpen = openRun?.length ?? 0;
const lenClose = closeRun?.length ?? 0;
const isVariable = lenOpen === lenClose && lenOpen % 2 === 1;
if (!isVariable) return match; // even runs are just escapes
// 3) Number of literal braces outside the span
const outerCount = Math.floor(lenOpen / 2);
const outerLeft = "{".repeat(outerCount);
const outerRight = "}".repeat(outerCount);
return (
`${outerLeft}` +
`<span class="chat-message-highlight">{${varName}}</span>` +
`${outerRight}`
);
})
// preserve new-lines
.replace(/\n/g, "<br />");
const renderPromptText = () => (

View file

@ -37,7 +37,13 @@ export const INVALID_CHARACTERS = [
* It matches the variables in the text that are between {{}} or {}.
*/
export const regexHighlight = /\{\{(.*?)\}\}|\{([^{}]+)\}/g;
/**
* p1 fenced code block ```...```
* p2 opening brace run (one or more)
* p3 variable name (no braces)
* p4 closing brace run (one or more)
*/
export const regexHighlight = /(```[\s\S]*?```)|(\{+)([^{}]+)(\}+)/g;
export const specialCharsRegex = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`´]/;
export const programmingLanguages: languageMap = {

View file

@ -52,11 +52,18 @@ export default function PromptModal({
const textareaRef = useRef<HTMLTextAreaElement>(null);
function checkVariables(valueToCheck: string): void {
const regex = /\{([^{}]+)\}/g;
// Match *any* brace run around an identifier
const regex = /(\{+)([^{}]+)(\}+)/g;
const matches: string[] = [];
let match;
while ((match = regex.exec(valueToCheck))) {
matches.push(`{${match[1]}}`);
const [openRun, varName, closeRun] = [match[1], match[2], match[3]];
// keep only odd, balanced runs (actual variables)
if (openRun.length === closeRun.length && openRun.length % 2 === 1) {
matches.push(`{${varName}}`); // normalise to single-brace form
}
}
let invalid_chars: string[] = [];
@ -88,18 +95,22 @@ export default function PromptModal({
const coloredContent = (typeof inputValue === "string" ? inputValue : "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(regexHighlight, (match, p1, p2) => {
// Decide which group was matched. If p1 is not undefined, do nothing
// we don't want to change the text. If p2 is not undefined, then we
// have a variable, so we should highlight it.
// ! This will not work with multiline or indented json yet
if (p1 !== undefined) {
return match;
} else if (p2 !== undefined) {
return varHighlightHTML({ name: p2 });
}
.replace(regexHighlight, (match, openRun, varName, closeRun) => {
// 1) Only highlight when both sides are the *same* length and that
// length is odd ( 1,3,5,… ).
const lenOpen = openRun?.length ?? 0;
const lenClose = closeRun?.length ?? 0;
const isVariable = lenOpen === lenClose && lenOpen % 2 === 1;
return match;
if (!isVariable) return match; // even-brace runs ⇒ escape, no highlight
// 2) Number of literal braces each side = floor(lenOpen / 2)
const literal = "{".repeat(Math.floor(lenOpen / 2));
return (
literal +
varHighlightHTML({ name: varName }) +
literal.replace(/\{/g, "}") // same amount of closing braces
);
})
.replace(/\n/g, "<br />");

View file

@ -2,6 +2,136 @@ import { expect, test } from "@playwright/test";
import { adjustScreenView } from "../../utils/adjust-screen-view";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
import { zoomOut } from "../../utils/zoom-out";
// Helper function to verify prompt variables
async function verifyPromptVariables(
page,
template: string,
expectedVars: string[],
isFirstTime = true,
) {
await page.getByTestId("promptarea_prompt_template").click();
// Use different selectors based on whether this is the first time or a subsequent edit
if (isFirstTime) {
await page.getByTestId("modal-promptarea_prompt_template").fill(template);
// Verify the template is set correctly
const value = await page
.getByTestId("modal-promptarea_prompt_template")
.inputValue();
expect(value).toBe(template);
} else {
// For subsequent edits, we need to click the edit button first
await page.getByRole("dialog").getByTestId("edit-prompt-sanitized").click();
await page.getByTestId("modal-promptarea_prompt_template").fill(template);
// Verify the template is set correctly
const value = await page
.getByTestId("modal-promptarea_prompt_template")
.inputValue();
expect(value).toBe(template);
}
// Verify each expected variable has a badge
for (let i = 0; i < expectedVars.length; i++) {
const badgeText = await page.locator(`//*[@id="badge${i}"]`).innerText();
expect(badgeText).toBe(expectedVars[i]);
}
// Verify no extra badges exist
const extraBadge = await page
.locator(`//*[@id="badge${expectedVars.length}"]`)
.isVisible()
.catch(() => false);
expect(extraBadge).toBeFalsy();
await page.getByTestId("genericModalBtnSave").click();
}
test(
"PromptTemplateComponent - Variable Extraction",
{ tag: ["@release", "@workspace"] },
async ({ page }) => {
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("prompt");
await page.waitForSelector('[data-testid="promptsPrompt"]', {
timeout: 3000,
});
await page
.locator('//*[@id="promptsPrompt"]')
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Test basic variable extraction (first time)
await verifyPromptVariables(page, "Hello {name}!", ["name"], true);
// Test multiple variables (subsequent edit)
await verifyPromptVariables(
page,
"Hi {name}, you are {age} years old",
["name", "age"],
false,
);
// Test duplicate variables (should only show once)
await verifyPromptVariables(
page,
"Hello {name}! How are you {name}?",
["name"],
false,
);
// Test escaped variables with {{}}
await verifyPromptVariables(
page,
"Escaped {{not_a_var}} but {real_var} works",
["real_var"],
false,
);
// Test complex template
await verifyPromptVariables(
page,
"Hello {name}! Your score is {{4 + 5}}, age: {age}",
["name", "age"],
false,
);
// Test multiline template
await verifyPromptVariables(
page,
`Multi-line with {var1}
and {var2} plus
{var3} at the end`,
["var1", "var2", "var3"],
false,
);
// Final verification - check that the template persists
await page.getByTestId("div-generic-node").click();
await page.getByTestId("edit-button-modal").last().click();
const savedTemplate = await page
.locator('//*[@id="promptarea_prompt_edit_template"]')
.innerText();
expect(savedTemplate).toBe(
"Multi-line with {var1}\n and {var2} plus\n {var3} at the end",
);
// Close the final modal
await page.getByText("Close").last().click();
},
);
test(
"PromptTemplateComponent",
{ tag: ["@release", "@workspace"] },