Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
17ad18c
fix(prompts): reject prompt variables starting with an underscore
tarciorodrigues Aug 6, 2026
d27e27f
test(prompts): cover the reserved underscore namespace in both syntaxes
tarciorodrigues Aug 6, 2026
b4d05a6
feat(prompts): add shared predicate for reserved prompt variable names
tarciorodrigues Aug 6, 2026
53a61b8
feat(prompts): flag reserved variable names while typing in the promp…
tarciorodrigues Aug 6, 2026
261da77
feat(prompts): flag reserved variable names in the double-bracket editor
tarciorodrigues Aug 6, 2026
f662e74
feat(prompts): flag reserved variable names in the node preview
tarciorodrigues Aug 6, 2026
da24fa8
fix(prompts): restore variable highlighting in the f-string prompt ed…
tarciorodrigues Aug 6, 2026
598be3f
feat(prompts): flag reserved variable names in the accordion prompt p…
tarciorodrigues Aug 6, 2026
c53694f
fix(prompts): backtick the names in the rejection message
tarciorodrigues Aug 6, 2026
d4f78df
fix(prompts): keep the braces inside the highlight in the f-string ed…
tarciorodrigues Aug 6, 2026
8ad9d22
fix(prompts): escape the tooltip text before it enters the title attr…
tarciorodrigues Aug 6, 2026
a086caa
style(prompts): format the prompt preview test and drop its any cast
tarciorodrigues Aug 6, 2026
9b08311
feat(prompts): mark every invalid variable name in the prompt editor
tarciorodrigues Aug 7, 2026
fcd9204
fix(prompts): drop the caret-position any cast that fails the CI lint
tarciorodrigues Aug 7, 2026
83c51d5
Merge branch 'release-1.12.0' into fix/le-2144-underscore-prompt-vari…
tarciorodrigues Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ def test_multiple_simple_variables_accepted(self):
assert sorted(result) == ["first_name", "last_name"]

def test_underscore_variables_accepted(self):
"""Test that variables with underscores are accepted."""
result = validate_prompt("{{user_name}} - {{_private}}", is_mustache=True)
assert sorted(result) == ["_private", "user_name"]
"""Test that variables with underscores are accepted.

A leading underscore is rejected -- see TestValidatePromptReservedPrefix in
test_validate_prompt_reserved_prefix.py. Underscores anywhere else are fine.
"""
result = validate_prompt("{{user_name}} - {{private_}}", is_mustache=True)
assert sorted(result) == ["private_", "user_name"]

def test_numeric_suffix_variables_accepted(self):
"""Test that variables with numeric suffixes are accepted."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for the reserved `_*` namespace in prompt variable names.

Keys prefixed with an underscore are node-template metadata (`_type`,
`_frontend_node_flow_id`, ...), not component fields. The frontend filters that namespace
out of every render path, so a variable such as `{_x}` used to be accepted by
`validate_prompt` and written into the template while never producing an input field or a
handle -- it could not be given a value and resolved to an empty string at run time.

Regression test for LE-2144. The case list is mirrored by the frontend unit test for
`isReservedVariableName`; keep the two in sync.
"""

import pytest
from lfx.base.prompts.api_utils import validate_prompt

REJECTED = ["_x", "_", "__y", "_type", "_frontend_node_flow_id"]
ACCEPTED = ["var", "a_b", "var_1", "private_", "x"]


class TestValidatePromptReservedPrefix:
"""Variable names starting with an underscore are rejected in both syntaxes."""

@pytest.mark.parametrize("name", REJECTED)
def test_leading_underscore_rejected_fstring(self, name):
with pytest.raises(ValueError, match="cannot start with `_`"):
validate_prompt(f"Hello {{{name}}}, how are you?")

@pytest.mark.parametrize("name", REJECTED)
def test_leading_underscore_rejected_mustache(self, name):
with pytest.raises(ValueError, match="cannot start with `_`"):
validate_prompt(f"Hello {{{{{name}}}}}, how are you?", is_mustache=True)

@pytest.mark.parametrize("name", ACCEPTED)
def test_regular_names_still_accepted_fstring(self, name):
assert validate_prompt(f"Hello {{{name}}}, how are you?") == [name]

@pytest.mark.parametrize("name", ACCEPTED)
def test_regular_names_still_accepted_mustache(self, name):
assert validate_prompt(f"Hello {{{{{name}}}}}, how are you?", is_mustache=True) == [name]

def test_error_names_only_the_offending_variables(self):
"""A mixed template reports the rejected names, not every variable in it."""
with pytest.raises(ValueError, match=r"Invalid input variables: `_a`, `_b`\.") as exc_info:
validate_prompt("{name} {_a} {city} {_b}")
assert "name" not in str(exc_info.value).split(".")[0]

def test_names_are_backticked_so_markdown_keeps_the_underscores(self):
"""The frontend renders this message with react-markdown.

Bare underscores pair up into emphasis markers there, so `_x` would reach the
user as `x` and the rule itself would read "cannot start with ''".
"""
with pytest.raises(ValueError, match="Invalid input variables") as exc_info:
validate_prompt("{_a} {_b}")
message = str(exc_info.value)
assert "`_a`" in message
assert "`_b`" in message
assert "start with `_`" in message

def test_metadata_key_no_longer_reaches_the_template_writer(self):
"""`{_type}` used to pass validation and then fail with an opaque HTTP 500.

`add_new_variables_to_template` read `template["_type"]["value"]` on the plain
string that holds the node type, raising `string indices must be integers`.
It is now rejected up front with an actionable message.
"""
with pytest.raises(ValueError, match="cannot start with `_`"):
validate_prompt("Hello {_type}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getHighlightedHTML } from "../prompt-highlight";

// This helper drives the prompt preview the user actually sees on the node whenever the
// inspection panel is enabled, so the reserved-name marking has to reach it too.
describe("getHighlightedHTML", () => {
describe("f-string", () => {
it("marks a name starting with an underscore as invalid", () => {
expect(getHighlightedHTML("Hello {_x}!", false)).toContain(
'<span class="chat-message-highlight-invalid">{_x}</span>',
);
});

it("keeps a regular name highlighted normally", () => {
expect(getHighlightedHTML("Hello {var}!", false)).toContain(
'<span class="chat-message-highlight">{var}</span>',
);
});

it("marks only the offending variable in a mixed template", () => {
const html = getHighlightedHTML("Hello {_x}, meet {var}.", false);
expect(html).toContain(
'<span class="chat-message-highlight-invalid">{_x}</span>',
);
expect(html).toContain(
'<span class="chat-message-highlight">{var}</span>',
);
});

it("leaves an underscore that is not the first character valid", () => {
expect(getHighlightedHTML("Hi {user_name}!", false)).toContain(
'<span class="chat-message-highlight">{user_name}</span>',
);
});
});

describe("double brackets", () => {
it("marks a name starting with an underscore as invalid", () => {
expect(getHighlightedHTML("Hello {{_x}}!", true)).toContain(
'<span class="chat-message-highlight-invalid">{{_x}}</span>',
);
});

it("keeps a regular name highlighted normally", () => {
expect(getHighlightedHTML("Hello {{var}}!", true)).toContain(
'<span class="chat-message-highlight">{{var}}</span>',
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { regexHighlight } from "@/constants/constants";
import { variableHighlightClass } from "@/utils/promptVariables";

/** Apply variable highlighting to the prompt text. */
export const getHighlightedHTML = (text: string, isDoubleBrackets: boolean) => {
if (isDoubleBrackets) {
return text
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match) => {
return `<span class="chat-message-highlight">${match}</span>`;
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
return `<span class="${variableHighlightClass(varName)}">${match}</span>`;
});
}
return text
Expand All @@ -28,7 +29,7 @@ export const getHighlightedHTML = (text: string, isDoubleBrackets: boolean) => {

return (
`${outerLeft}` +
`<span class="chat-message-highlight">{${varName}}</span>` +
`<span class="${variableHighlightClass(varName)}">{${varName}}</span>` +
`${outerRight}`
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,17 +255,33 @@ describe("MustachePromptAreaComponent", () => {
);
});

it("should highlight variables starting with underscore", () => {
it("should mark variables starting with underscore as invalid", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Value: {{_private}}"
/>,
);

// The underscore namespace is reserved for template metadata, so the name will be
// rejected on Check & Save -- the preview says so instead of looking valid.
const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain(
'<span class="chat-message-highlight">{{_private}}</span>',
'<span class="chat-message-highlight-invalid">{{_private}}</span>',
);
});

it("should keep an underscore that is not the first character valid", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Value: {{user_name}}"
/>,
);

const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain(
'<span class="chat-message-highlight">{{user_name}}</span>',
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import SanitizedHTMLWrapper from "@/components/common/sanitizedHTMLWrapper";
import MustachePromptModal from "@/modals/mustachePromptModal";
import { variableHighlightClass } from "@/utils/promptVariables";
import { cn } from "../../../../../utils/utils";
import { Button } from "../../../../ui/button";
import { getNodeScopedDomId } from "../../helpers/get-node-scoped-dom-id";
Expand Down Expand Up @@ -32,8 +33,8 @@ export default function MustachePromptAreaComponent({
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// highlight only simple mustache variables {{variable_name}} - no complex syntax
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
return `<span class="chat-message-highlight">{{${varName}}}</span>`;
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (_match, varName) => {
return `<span class="${variableHighlightClass(varName)}">{{${varName}}}</span>`;
})
// preserve new-lines
.replace(/\n/g, "<br />");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { render, screen } from "@testing-library/react";
import PromptAreaComponent from "../index";

jest.mock("@/modals/promptModal", () => {
return function MockPromptModal({ children }: { children: React.ReactNode }) {
return <div data-testid="mock-prompt-modal">{children}</div>;
};
});

jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: { name: string; className?: string }) => (
<span data-testid={`icon-${name}`} className={className}>
{name}
</span>
),
}));

jest.mock("@/components/common/sanitizedHTMLWrapper", () => {
return function MockSanitizedHTMLWrapper({
content,
className,
}: {
content: string;
className?: string;
}) {
return (
<div
data-testid="sanitized-html"
className={className}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
};
});

describe("PromptAreaComponent variable highlighting", () => {
const defaultProps = {
field_name: "template",
nodeClass: undefined,
handleOnNewValue: jest.fn(),
handleNodeClass: jest.fn(),
value: "",
disabled: false,
editNode: false,
id: "prompt-template",
nodeId: "node-1",
readonly: false,
} as any;

it("marks a name starting with an underscore as invalid", () => {
render(<PromptAreaComponent {...defaultProps} value="Hello {_x}!" />);

expect(screen.getByTestId("sanitized-html").innerHTML).toContain(
'<span class="chat-message-highlight-invalid">{_x}</span>',
);
});

it("keeps a regular name highlighted normally", () => {
render(<PromptAreaComponent {...defaultProps} value="Hello {var}!" />);

expect(screen.getByTestId("sanitized-html").innerHTML).toContain(
'<span class="chat-message-highlight">{var}</span>',
);
});

it("keeps an underscore that is not the first character valid", () => {
render(<PromptAreaComponent {...defaultProps} value="Hi {user_name}!" />);

expect(screen.getByTestId("sanitized-html").innerHTML).toContain(
'<span class="chat-message-highlight">{user_name}</span>',
);
});

it("marks only the offending variable in a mixed template", () => {
render(
<PromptAreaComponent {...defaultProps} value="Hello {_x}, meet {var}." />,
);

const html = screen.getByTestId("sanitized-html").innerHTML;
expect(html).toContain(
'<span class="chat-message-highlight-invalid">{_x}</span>',
);
expect(html).toContain('<span class="chat-message-highlight">{var}</span>');
});

it("leaves double-brace escapes untouched", () => {
render(<PromptAreaComponent {...defaultProps} value="Literal {{_x}} here" />);

expect(screen.getByTestId("sanitized-html").innerHTML).not.toContain(
"chat-message-highlight-invalid",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
import SanitizedHTMLWrapper from "@/components/common/sanitizedHTMLWrapper";
import { regexHighlight } from "@/constants/constants";
import PromptModal from "@/modals/promptModal";
import { variableHighlightClass } from "@/utils/promptVariables";
import { cn } from "../../../../../utils/utils";
import { Button } from "../../../../ui/button";
import { getNodeScopedDomId } from "../../helpers/get-node-scoped-dom-id";
Expand Down Expand Up @@ -51,7 +52,7 @@ export default function PromptAreaComponent({

return (
`${outerLeft}` +
`<span class="chat-message-highlight">{${varName}}</span>` +
`<span class="${variableHighlightClass(varName)}">{${varName}}</span>` +
`${outerRight}`
);
})
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "Eingabeaufforderungsvariablen:",
"modal.prompt.title": "Eingabeaufforderung bearbeiten",
"modal.prompt.variablesHint": "Prompt-Variablen können unter einem beliebigen Namen in geschweiften Klammern angelegt werden, z. B. ` {variable_name} `",
"modal.prompt.reservedPrefix": "Variablennamen dürfen nicht mit „_“ beginnen. Dieses Präfix ist internen Feldern vorbehalten.",
"modal.restoreVersion": "Version wiederherstellen",
"modal.saveButton": "Speichern",
"modal.secretKey.createDescription": "Erstellen Sie einen geheimen API-Schlüssel, um die Langflow-API zu nutzen.",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@
"modal.prompt.title": "Edit Prompt",
"modal.prompt.promptVariables": "Prompt Variables:",
"modal.prompt.variablesHint": "Prompt variables can be created with any chosen name inside curly brackets, e.g. {variable_name}",
"modal.prompt.reservedPrefix": "Variable names can't start with \"_\". That prefix is reserved for internal fields.",
"modal.prompt.checkAndSave": "Check & Save",
"modal.api.title": "API access",
"modal.api.description": "API access requires an API key. You can",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "Variables de la línea de comandos:",
"modal.prompt.title": "Editar mensaje",
"modal.prompt.variablesHint": "Las variables de línea de comandos se pueden crear con cualquier nombre que se desee entre llaves, por ejemplo: ` {variable_name} `",
"modal.prompt.reservedPrefix": "Los nombres de variables no pueden empezar por \"_\". Ese prefijo está reservado para campos internos.",
"modal.restoreVersion": "Restaurar versión",
"modal.saveButton": "Guardar",
"modal.secretKey.createDescription": "Crea una clave API secreta para utilizar la API de Langflow.",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "Variables d'invite :",
"modal.prompt.title": "Modifier l'invite",
"modal.prompt.variablesHint": "Les variables de ligne de commande peuvent être créées avec le nom de votre choix entre accolades, par exemple : {variable_name}",
"modal.prompt.reservedPrefix": "Les noms de variables ne peuvent pas commencer par « _ ». Ce préfixe est réservé aux champs internes.",
"modal.restoreVersion": "Restaurer la version",
"modal.saveButton": "Sauvegarder",
"modal.secretKey.createDescription": "Créez une clé API secrète pour utiliser l'API Langflow.",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "プロンプト変数:",
"modal.prompt.title": "プロンプトを編集",
"modal.prompt.variablesHint": "プロンプト変数は、中括弧({})の中に任意の名前を指定して作成できます。例: {variable_name}",
"modal.prompt.reservedPrefix": "変数名は「_」で始めることはできません。このプレフィックスは内部フィールド用に予約されています。",
"modal.restoreVersion": "復元バージョン",
"modal.saveButton": "保存",
"modal.secretKey.createDescription": "Langflow API を使用するための秘密の API キーを作成してください。",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "Variáveis de prompt:",
"modal.prompt.title": "Editar prompt",
"modal.prompt.variablesHint": "As variáveis de prompt podem ser criadas com qualquer nome escolhido entre chaves, por exemplo: ` {variable_name} `",
"modal.prompt.reservedPrefix": "Nomes de variáveis não podem começar com \"_\". Esse prefixo é reservado para campos internos.",
"modal.restoreVersion": "Restaurar versão",
"modal.saveButton": "Salvar",
"modal.secretKey.createDescription": "Crie uma chave API secreta para usar a API do Langflow.",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,7 @@
"modal.prompt.promptVariables": "提示变量:",
"modal.prompt.title": "编辑提示",
"modal.prompt.variablesHint": "可以在大括号内使用任意名称创建提示变量,例如: {variable_name}",
"modal.prompt.reservedPrefix": "变量名不能以“_”开头。该前缀保留给内部字段使用。",
"modal.restoreVersion": "恢复版本",
"modal.saveButton": "保存",
"modal.secretKey.createDescription": "创建一个密钥以使用 Langflow API。",
Expand Down
Loading
Loading