From abe35766353814c25a85ca38d47173ce9b1b67dc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 00:55:43 +0200 Subject: [PATCH 01/41] feat(frontend): preserve native JSON in evaluator execution inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove normalizeCompact() wraps from buildEvaluatorExecutionInputs in runnable/utils.ts so testcase values, upstream output, and ground_truth arrive at the backend as native JSON (object/array/number/...) rather than stringified text. Required for mustache nested access (e.g. {{geo.region}}) to work over object-typed variables. The completion-path was already preserving native types post-#4394 (loadableController.selectors.row().data is native). Added a clarifying comment to toDisplayString in execution/selectors.ts to prevent future misuse for transport — it is a UI-display helper only. 13 new tests in build-evaluator-execution-inputs.test.ts pin the contract: schema-driven and legacy paths, object/array/primitive preservation, gap-04 invariant (JSON-shaped strings stay strings). --- .../agenta-entities/src/runnable/utils.ts | 51 ++-- .../build-evaluator-execution-inputs.test.ts | 242 ++++++++++++++++++ .../src/state/execution/selectors.ts | 16 ++ 3 files changed, 276 insertions(+), 33 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/build-evaluator-execution-inputs.test.ts diff --git a/web/packages/agenta-entities/src/runnable/utils.ts b/web/packages/agenta-entities/src/runnable/utils.ts index b8bf683ba1..dc5372b8a5 100644 --- a/web/packages/agenta-entities/src/runnable/utils.ts +++ b/web/packages/agenta-entities/src/runnable/utils.ts @@ -807,7 +807,10 @@ const TESTCASE_OBJECT_KEYS = new Set(["inputs"]) export function buildEvaluatorExecutionInputs(ctx: EvaluatorInputContext): Record { const {testcaseData, upstreamOutput, settings, inputSchema} = ctx - const prediction = normalizeCompact(upstreamOutput) + // RFC invariant: native JSON stays native until template rendering. + // We pass `upstreamOutput` through as-is (object, array, string, primitive) + // and expose it under both `prediction` and `outputs` keys downstream — + // they are the same value, not a stringified copy and a native copy. const schemaProperties = inputSchema?.properties && typeof inputSchema.properties === "object" @@ -820,13 +823,12 @@ export function buildEvaluatorExecutionInputs(ctx: EvaluatorInputContext): Recor inputSchema: inputSchema!, testcaseData, upstreamOutput, - prediction, settings, }) } // Legacy fallback — no schema available - return buildLegacy({testcaseData, prediction, settings}) + return buildLegacy({testcaseData, upstreamOutput, settings}) } /** @@ -838,10 +840,9 @@ function buildFromSchema(ctx: { inputSchema: Record testcaseData: Record upstreamOutput: unknown - prediction: string settings: Record }): Record { - const {schemaProperties, inputSchema, testcaseData, upstreamOutput, prediction, settings} = ctx + const {schemaProperties, inputSchema, testcaseData, upstreamOutput, settings} = ctx const inputs: Record = {} for (const key of Object.keys(schemaProperties)) { @@ -854,13 +855,15 @@ function buildFromSchema(ctx: { const columnName = keySettingValue.startsWith("testcase.") ? keySettingValue.split(".")[1] : keySettingValue - inputs[key] = normalizeCompact(testcaseData[columnName]) + // RFC invariant: native value passes through, not stringified. + inputs[key] = testcaseData[columnName] continue } - // 2. Known upstream output keys + // 2. Known upstream output keys — both `prediction` and `outputs` + // expose the SAME native upstream value; do not stringify either one. if (UPSTREAM_OUTPUT_KEYS.has(key)) { - inputs[key] = key === "prediction" ? prediction : normalizeCompact(upstreamOutput) + inputs[key] = upstreamOutput continue } @@ -887,9 +890,9 @@ function buildFromSchema(ctx: { } } - // Ensure upstream output is always present in some form + // Ensure upstream output is always present in some form (native, both keys). if (!("prediction" in inputs) && !("outputs" in inputs)) { - inputs.prediction = prediction + inputs.prediction = upstreamOutput inputs.outputs = upstreamOutput } @@ -902,10 +905,10 @@ function buildFromSchema(ctx: { */ function buildLegacy(ctx: { testcaseData: Record - prediction: string + upstreamOutput: unknown settings: Record }): Record { - const {testcaseData, prediction, settings} = ctx + const {testcaseData, upstreamOutput, settings} = ctx const correctAnswerKey = settings.correct_answer_key const groundTruthKey = @@ -915,12 +918,12 @@ function buildLegacy(ctx: { ? correctAnswerKey : undefined - const rawGT = groundTruthKey ? testcaseData[groundTruthKey] : undefined - const ground_truth = normalizeCompact(rawGT) + // RFC invariant: native ground-truth value passes through, not stringified. + const ground_truth = groundTruthKey ? testcaseData[groundTruthKey] : undefined const inputs: Record = { ...testcaseData, - prediction, + prediction: upstreamOutput, } if (groundTruthKey) { @@ -1027,24 +1030,6 @@ export function validateEvaluatorInputs(ctx: EvaluatorInputContext): EvaluatorIn return {valid: true, missingInputs: []} } -/** - * Normalize a value to a compact string representation. - * Mirrors DebugSection's `normalizeCompact` helper. - */ -function normalizeCompact(val: unknown): string { - if (val === undefined || val === null) return "" - const str = typeof val === "string" ? val : JSON.stringify(val) - try { - const parsed = JSON.parse(str) - if (parsed && typeof parsed === "object") { - return JSON.stringify(parsed) - } - return str - } catch { - return str - } -} - /** * Transform trace-prefixed keys in evaluator settings. * Strips `trace.` prefix from setting values (e.g. `"trace.spans.output"` → `"spans.output"`). diff --git a/web/packages/agenta-entities/tests/unit/build-evaluator-execution-inputs.test.ts b/web/packages/agenta-entities/tests/unit/build-evaluator-execution-inputs.test.ts new file mode 100644 index 0000000000..65538263ea --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/build-evaluator-execution-inputs.test.ts @@ -0,0 +1,242 @@ +/** + * Unit tests for buildEvaluatorExecutionInputs — native JSON transport. + * + * Pins the RFC invariant: "native JSON stays native until template rendering." + * Object and array values from testcase / upstream output / ground_truth must + * survive transport as native JSON, NOT as stringified text. This is what + * lets `{{geo.region}}` work in mustache against an object-typed variable. + * + * Pre-Step-1 behavior: `normalizeCompact` stringifies every non-string value. + * Tests in this file FAIL until Step 1 (transport surgery) lands and pass after. + * + * Strings that happen to contain JSON-shaped text MUST stay strings — + * the runtime should never silently parse them. (RFC gap-04.) + */ + +import {describe, it, expect} from "vitest" + +import {buildEvaluatorExecutionInputs, type EvaluatorInputContext} from "../../src/runnable/utils" + +const makeCtx = (overrides: Partial = {}): EvaluatorInputContext => ({ + testcaseData: {}, + upstreamOutput: undefined, + settings: {}, + inputSchema: null, + ...overrides, +}) + +describe("buildEvaluatorExecutionInputs — native JSON transport", () => { + // ── Schema-driven path ────────────────────────────────────────────────── + + describe("with inputSchema (schema-driven)", () => { + it("preserves object value resolved via a *_key setting", () => { + const geo = {region: "Pacific Islands", subregion: "Western Melanesia"} + const ctx = makeCtx({ + testcaseData: {geography_col: geo}, + settings: {geo_key: "geography_col"}, + inputSchema: { + type: "object", + properties: {geo: {type: "object"}}, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.geo).toBe(geo) // same reference — not stringified + expect(typeof inputs.geo).toBe("object") + }) + + it("preserves array value via direct schema property match", () => { + const languages = ["en", "bi", "fr"] + const ctx = makeCtx({ + testcaseData: {languages}, + inputSchema: { + type: "object", + properties: {languages: {type: "array"}}, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.languages).toBe(languages) + expect(Array.isArray(inputs.languages)).toBe(true) + }) + + it("preserves object upstream output as `outputs` / `prediction`", () => { + const upstreamOutput = {answer: "Port Vila", iso: "VU"} + const ctx = makeCtx({ + upstreamOutput, + inputSchema: { + type: "object", + properties: {outputs: {type: "object"}, prediction: {type: "object"}}, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.outputs).toBe(upstreamOutput) + // `prediction` should be the native value too — not a stringified copy. + expect(inputs.prediction).toEqual(upstreamOutput) + expect(typeof inputs.prediction).toBe("object") + }) + + it("keeps strings that look like JSON as strings (gap-04)", () => { + const metadata = '{"source":"trace","trace_id":"vu-001"}' + const ctx = makeCtx({ + testcaseData: {metadata}, + inputSchema: { + type: "object", + properties: {metadata: {type: "string"}}, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.metadata).toBe(metadata) + expect(typeof inputs.metadata).toBe("string") + }) + + it("preserves nested object via additionalProperties spread", () => { + const profile = {name: "Ada", tags: ["admin"]} + const ctx = makeCtx({ + testcaseData: {profile, age: 30}, + inputSchema: { + type: "object", + properties: {age: {type: "number"}}, + // additionalProperties not explicitly false → spread allowed + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.profile).toBe(profile) + expect(inputs.age).toBe(30) + }) + + it("preserves primitive types unchanged (number, boolean)", () => { + const ctx = makeCtx({ + testcaseData: {age: 42, is_active: true, ratio: 3.14}, + inputSchema: { + type: "object", + properties: { + age: {type: "number"}, + is_active: {type: "boolean"}, + ratio: {type: "number"}, + }, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.age).toBe(42) + expect(inputs.is_active).toBe(true) + expect(inputs.ratio).toBe(3.14) + }) + }) + + // ── Legacy path (no schema) ────────────────────────────────────────────── + + describe("without inputSchema (legacy fallback)", () => { + it("spreads testcase data preserving native object/array values", () => { + const geo = {region: "Pacific Islands"} + const languages = ["en", "bi"] + const ctx = makeCtx({ + testcaseData: {country: "Vanuatu", geo, languages, age: 320}, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.country).toBe("Vanuatu") + expect(inputs.geo).toBe(geo) + expect(inputs.languages).toBe(languages) + expect(inputs.age).toBe(320) + }) + + it("preserves object upstream output as `prediction`", () => { + const upstreamOutput = {answer: "Port Vila", iso: "VU"} + const ctx = makeCtx({ + testcaseData: {country: "Vanuatu"}, + upstreamOutput, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + // `prediction` is always present in legacy path + expect(inputs.prediction).toEqual(upstreamOutput) + expect(typeof inputs.prediction).toBe("object") + }) + + it("preserves object ground_truth resolved via correct_answer_key", () => { + const correctAnswer = {capital: "Port Vila", iso: "VU"} + const ctx = makeCtx({ + testcaseData: {answer_col: correctAnswer, question: "?"}, + settings: {correct_answer_key: "answer_col"}, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + // Aliased under both `ground_truth` and the original column name. + expect(inputs.ground_truth).toBe(correctAnswer) + expect(inputs.answer_col).toBe(correctAnswer) + expect(typeof inputs.ground_truth).toBe("object") + }) + + it("handles testcase. prefix in correct_answer_key", () => { + const correctAnswer = ["Port Vila"] + const ctx = makeCtx({ + testcaseData: {answer_col: correctAnswer}, + settings: {correct_answer_key: "testcase.answer_col"}, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.ground_truth).toBe(correctAnswer) + expect(Array.isArray(inputs.ground_truth)).toBe(true) + }) + + it("keeps strings that look like JSON as strings (gap-04)", () => { + const metadata = '{"source":"trace"}' + const ctx = makeCtx({ + testcaseData: {metadata, country: "Vanuatu"}, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.metadata).toBe(metadata) + expect(typeof inputs.metadata).toBe("string") + }) + + it("handles null and undefined values without stringifying", () => { + const ctx = makeCtx({ + testcaseData: {nullable: null, missing: undefined, present: "x"}, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.nullable).toBeNull() + expect(inputs.missing).toBeUndefined() + expect(inputs.present).toBe("x") + }) + }) + + // ── Special "inputs" key (TESTCASE_OBJECT_KEYS) ───────────────────────── + + describe('special "inputs" key', () => { + it("passes the whole testcaseData object as `inputs` when schema requests it", () => { + const testcaseData = {country: "Vanuatu", age: 320, geo: {region: "Pacific"}} + const ctx = makeCtx({ + testcaseData, + inputSchema: { + type: "object", + properties: {inputs: {type: "object"}}, + }, + }) + + const inputs = buildEvaluatorExecutionInputs(ctx) + + expect(inputs.inputs).toBe(testcaseData) + // Nested object inside is also native, not stringified. + expect((inputs.inputs as Record).geo).toEqual({region: "Pacific"}) + }) + }) +}) diff --git a/web/packages/agenta-playground/src/state/execution/selectors.ts b/web/packages/agenta-playground/src/state/execution/selectors.ts index a2de61ec1c..070755f588 100644 --- a/web/packages/agenta-playground/src/state/execution/selectors.ts +++ b/web/packages/agenta-playground/src/state/execution/selectors.ts @@ -37,6 +37,22 @@ import {displayedEntityIdsAtom} from "./displayedEntities" import {createExecutionItemHandle, type ExecutionItemLifecycleSnapshot} from "./executionItems" import type {RunStatus} from "./types" +/** + * UI-display helper. Converts a cell value to a string for rendering inside + * a text-mode editor. + * + * ⚠️ MUST NOT be used to build invocation payloads. Native JSON must reach + * the runtime as native JSON (RFC: "native JSON stays native until template + * rendering"). The invocation path reads testcase data directly from + * `loadableController.selectors.row(...).data` — preserves native types. + * + * This helper feeds only the controller-surface atoms + * (`rowVariableValueAtomFamily`, `testcaseCellValueAtomFamily`) that hand + * string output to React components for the text-mode SharedEditor. When the + * V2 input UX adds Form / JSON / YAML modes (Step 3), those modes consume + * the native value directly via `testcaseMolecule.atoms.cell(...)` — they + * do not route through this helper. + */ const toDisplayString = (value: unknown): string => { if (value === undefined || value === null) return "" if (typeof value === "string") return value From fe7b85f088c2a3098f93cc3395ecd88594d530e3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:02:57 +0200 Subject: [PATCH 02/41] feat(frontend): promote V2 view-mode primitives to @agenta/entity-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Mahmoud's V2 view-mode vocabulary from the design-mockups POC (web/apps/design-mockups/src/components/proposed/) into @agenta/entity-ui under a new ./view-types subpath. Exports: - ViewType (6-way: text / markdown / chat / form / json / yaml) - FieldKind (4-way bucketing for view-options decision) - NestedKind (precise nested kind for form widgets) - isChatMessagesArray, detectFieldKind, detectNestedKind - getViewOptions, getDefaultViewForValue - ViewTypeSelect (the "View as ▾" dropdown component) - FormView (recursive form rendering for objects/arrays) - Pure formatters: valueToDisplay, coerceTextEdit, parseJsonEdit, parseYamlEdit The chip vocabulary stays distinct from FieldKind. For type chips, consumers use TypeChip + inferLogicalType from @agenta/ui + @agenta/shared (granular: string / number / boolean / null / json-object / json-array). V2's FieldKind is INTERNAL to view-options decision logic only. Tests live in agenta-entities/tests/unit (stopgap until entity-ui gets its own vitest runner from #4393): 22 view-types tests + 23 formatters tests pinning the contract. --- .../unit/playground-inputs-formatters.test.ts | 158 ++++++ .../tests/unit/view-types.test.ts | 185 +++++++ web/packages/agenta-entity-ui/package.json | 1 + .../src/view-types/FormView.tsx | 457 ++++++++++++++++++ .../src/view-types/ViewTypeSelect.tsx | 112 +++++ .../src/view-types/formatters.ts | 154 ++++++ .../agenta-entity-ui/src/view-types/index.ts | 35 ++ .../src/view-types/viewTypes.ts | 129 +++++ 8 files changed, 1231 insertions(+) create mode 100644 web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/view-types.test.ts create mode 100644 web/packages/agenta-entity-ui/src/view-types/FormView.tsx create mode 100644 web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx create mode 100644 web/packages/agenta-entity-ui/src/view-types/formatters.ts create mode 100644 web/packages/agenta-entity-ui/src/view-types/index.ts create mode 100644 web/packages/agenta-entity-ui/src/view-types/viewTypes.ts diff --git a/web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts b/web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts new file mode 100644 index 0000000000..d4374fce06 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for the pure formatters in @agenta/entity-ui/view-types. + * + * Same stopgap-location reasoning as `view-types.test.ts`: vitest runner + * lives in agenta-entities; the formatters live in @agenta/entity-ui. + * Cross-package relative import below is a test-time dep only. + * + * TODO(follow-up): Move these into agenta-entity-ui/tests/unit/ once that + * package gets its own vitest runner. + */ +import {describe, expect, it} from "vitest" + +import { + coerceTextEdit, + parseJsonEdit, + parseYamlEdit, + valueToDisplay, +} from "../../../agenta-entity-ui/src/view-types/formatters" + +describe("formatters: valueToDisplay", () => { + describe("nullish handling", () => { + it("returns '' for null and undefined in every mode", () => { + for (const mode of ["text", "markdown", "json", "yaml"] as const) { + expect(valueToDisplay(null, mode)).toBe("") + expect(valueToDisplay(undefined, mode)).toBe("") + } + }) + }) + + describe("text and markdown mode", () => { + it("passes strings through unchanged", () => { + expect(valueToDisplay("hello", "text")).toBe("hello") + expect(valueToDisplay("hello", "markdown")).toBe("hello") + }) + + it("converts primitives via String()", () => { + expect(valueToDisplay(42, "text")).toBe("42") + expect(valueToDisplay(true, "text")).toBe("true") + expect(valueToDisplay(false, "text")).toBe("false") + }) + + it("renders objects/arrays as compact JSON (matches backend whole-value insertion)", () => { + expect(valueToDisplay({a: 1}, "text")).toBe('{"a":1}') + expect(valueToDisplay([1, 2], "text")).toBe("[1,2]") + }) + + it("keeps a JSON-shaped string as the raw string (gap-04: strings stay strings)", () => { + const raw = '{"x":1}' + expect(valueToDisplay(raw, "text")).toBe(raw) + }) + }) + + describe("json mode", () => { + it("pretty-prints native objects and arrays", () => { + expect(valueToDisplay({a: 1}, "json")).toBe('{\n "a": 1\n}') + expect(valueToDisplay([1, 2], "json")).toBe("[\n 1,\n 2\n]") + }) + + it("pretty-prints strings that ARE valid JSON", () => { + expect(valueToDisplay('{"a":1}', "json")).toBe('{\n "a": 1\n}') + }) + + it("returns the raw string when it is NOT valid JSON", () => { + expect(valueToDisplay("hello", "json")).toBe("hello") + }) + + it("stringifies primitives as JSON literals", () => { + expect(valueToDisplay(42, "json")).toBe("42") + expect(valueToDisplay(true, "json")).toBe("true") + }) + }) + + describe("yaml mode", () => { + it("dumps native objects as YAML", () => { + const out = valueToDisplay({a: 1, b: "two"}, "yaml") + expect(out).toContain("a: 1") + expect(out).toContain("b: two") + }) + + it("dumps strings that ARE valid JSON as YAML", () => { + const out = valueToDisplay('{"a":1}', "yaml") + expect(out).toContain("a: 1") + }) + + it("returns the raw string when it is not valid JSON", () => { + expect(valueToDisplay("hello world", "yaml").trim()).toBe("hello world") + }) + }) +}) + +describe("formatters: coerceTextEdit", () => { + it("preserves number type for numeric originals", () => { + expect(coerceTextEdit("320", "number")).toBe(320) + expect(coerceTextEdit("3.14", "number")).toBe(3.14) + expect(coerceTextEdit("0", "number")).toBe(0) + expect(coerceTextEdit("-5", "number")).toBe(-5) + }) + + it("falls back to the raw string for invalid number edits", () => { + expect(coerceTextEdit("not-a-number", "number")).toBe("not-a-number") + }) + + it("returns '' (empty string sentinel) for empty number edits", () => { + // Number(\"\") is 0, but that's a worse default than letting the + // caller decide. Return empty string so caller can treat as "clear". + expect(coerceTextEdit("", "number")).toBe("") + }) + + it("coerces 'true'/'false' for boolean originals", () => { + expect(coerceTextEdit("true", "boolean")).toBe(true) + expect(coerceTextEdit("false", "boolean")).toBe(false) + }) + + it("keeps the raw string for non-canonical boolean edits", () => { + expect(coerceTextEdit("yes", "boolean")).toBe("yes") + expect(coerceTextEdit("1", "boolean")).toBe("1") + }) + + it("coerces empty back to null for null originals", () => { + expect(coerceTextEdit("", "null")).toBeNull() + expect(coerceTextEdit("something", "null")).toBe("something") + }) + + it("passes strings through unchanged", () => { + expect(coerceTextEdit("hello", "string")).toBe("hello") + expect(coerceTextEdit('{"x":1}', "string")).toBe('{"x":1}') + }) +}) + +describe("formatters: parseJsonEdit", () => { + it("returns {ok: true, value} for valid JSON", () => { + expect(parseJsonEdit('{"a":1}')).toEqual({ok: true, value: {a: 1}}) + expect(parseJsonEdit("[1,2,3]")).toEqual({ok: true, value: [1, 2, 3]}) + expect(parseJsonEdit("42")).toEqual({ok: true, value: 42}) + expect(parseJsonEdit("true")).toEqual({ok: true, value: true}) + expect(parseJsonEdit("null")).toEqual({ok: true, value: null}) + }) + + it("returns {ok: false} on parse failure", () => { + expect(parseJsonEdit("not json")).toEqual({ok: false}) + expect(parseJsonEdit("{a:1}")).toEqual({ok: false}) + expect(parseJsonEdit("")).toEqual({ok: false}) + }) +}) + +describe("formatters: parseYamlEdit", () => { + it("returns {ok: true, value} for valid YAML", () => { + const res = parseYamlEdit("a: 1\nb: two") + expect(res.ok).toBe(true) + if (res.ok) expect(res.value).toEqual({a: 1, b: "two"}) + }) + + it("accepts plain strings (YAML treats them as scalars)", () => { + const res = parseYamlEdit("hello world") + expect(res.ok).toBe(true) + if (res.ok) expect(res.value).toBe("hello world") + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/view-types.test.ts b/web/packages/agenta-entities/tests/unit/view-types.test.ts new file mode 100644 index 0000000000..2a3667c275 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/view-types.test.ts @@ -0,0 +1,185 @@ +/** + * Unit tests for the view-types primitives in @agenta/entity-ui/view-types. + * + * NOTE: This test file lives in @agenta/entities because that package already + * has a vitest runner wired up. The agenta-entity-ui package does not yet + * ship its own test runner. The relative import below crosses the package + * boundary deliberately to avoid: + * - Adding @agenta/entity-ui as a (test-time) dep of @agenta/entities, + * which would create a dependency cycle since entity-ui depends on + * entities at runtime. + * - Standing up a full vitest + stubs setup in agenta-entity-ui as part + * of this branch. + * + * TODO(follow-up): Move these tests into agenta-entity-ui/tests/unit/ once + * that package gets its own vitest runner. Tracked separately from this PR. + */ +import {describe, expect, it} from "vitest" + +import { + detectFieldKind, + detectNestedKind, + getDefaultViewForValue, + getViewOptions, + isChatMessagesArray, +} from "../../../agenta-entity-ui/src/view-types/viewTypes" + +describe("view-types: isChatMessagesArray", () => { + it("detects a basic role-tagged messages array", () => { + const messages = [ + {role: "system", content: "you are a helper"}, + {role: "user", content: "hi"}, + {role: "assistant", content: "hello!"}, + ] + expect(isChatMessagesArray(messages)).toBe(true) + }) + + it("accepts every supported role (system/user/assistant/tool/developer/function)", () => { + const messages = [ + {role: "system", content: "x"}, + {role: "user", content: "x"}, + {role: "assistant", content: "x"}, + {role: "tool", content: "x"}, + {role: "developer", content: "x"}, + {role: "function", content: "x"}, + ] + expect(isChatMessagesArray(messages)).toBe(true) + }) + + it("rejects empty arrays", () => { + expect(isChatMessagesArray([])).toBe(false) + }) + + it("rejects arrays of plain objects without role", () => { + expect(isChatMessagesArray([{content: "x"}, {content: "y"}])).toBe(false) + }) + + it("rejects arrays with an unrecognized role on any item", () => { + expect( + isChatMessagesArray([ + {role: "user", content: "x"}, + {role: "bogus", content: "y"}, + ]), + ).toBe(false) + }) + + it("rejects non-arrays (string, object, null, undefined)", () => { + expect(isChatMessagesArray("hi")).toBe(false) + expect(isChatMessagesArray({role: "user"})).toBe(false) + expect(isChatMessagesArray(null)).toBe(false) + expect(isChatMessagesArray(undefined)).toBe(false) + }) +}) + +describe("view-types: detectFieldKind (top-level 4-way bucketing)", () => { + it("buckets strings, numbers, nulls into 'string'", () => { + expect(detectFieldKind("hello")).toBe("string") + expect(detectFieldKind(42)).toBe("string") + expect(detectFieldKind(null)).toBe("string") + expect(detectFieldKind(undefined)).toBe("string") + }) + + it("returns 'boolean' for booleans", () => { + expect(detectFieldKind(true)).toBe("boolean") + expect(detectFieldKind(false)).toBe("boolean") + }) + + it("returns 'object' for plain objects and non-message arrays", () => { + expect(detectFieldKind({a: 1})).toBe("object") + expect(detectFieldKind(["a", "b"])).toBe("object") + expect(detectFieldKind([1, 2, 3])).toBe("object") + }) + + it("returns 'chat' for arrays of role-tagged messages (overrides 'object')", () => { + expect( + detectFieldKind([ + {role: "user", content: "hi"}, + {role: "assistant", content: "hello"}, + ]), + ).toBe("chat") + }) + + it("keeps a JSON-shaped string as 'string' (gap-04 invariant)", () => { + // The chip says what the value IS, not what it looks like. + // A string that contains JSON text is still a string. + expect(detectFieldKind('{"a":1}')).toBe("string") + }) +}) + +describe("view-types: detectNestedKind (precise nested 6-way)", () => { + it("distinguishes string / number / boolean / null", () => { + expect(detectNestedKind("x")).toBe("string") + expect(detectNestedKind(0)).toBe("number") + expect(detectNestedKind(true)).toBe("boolean") + expect(detectNestedKind(null)).toBe("null") + }) + + it("distinguishes object from array (unlike detectFieldKind)", () => { + expect(detectNestedKind({a: 1})).toBe("object") + expect(detectNestedKind([1, 2])).toBe("array") + }) + + it("treats undefined as 'string' (matches form-widget fallback)", () => { + // Undefined doesn't have its own widget; defaulting to string lets + // the Input handle it gracefully. + expect(detectNestedKind(undefined)).toBe("string") + }) +}) + +describe("view-types: getViewOptions (per-value dropdown options)", () => { + it("offers Text / Markdown / JSON / YAML for strings — Text is default", () => { + const opts = getViewOptions("hello") + const values = opts.map((o) => o.value) + expect(values).toEqual(["text", "markdown", "json", "yaml"]) + expect(opts[0].hint).toBe("default") + }) + + it("offers Text / JSON / YAML for booleans — Text is default", () => { + const opts = getViewOptions(true) + expect(opts.map((o) => o.value)).toEqual(["text", "json", "yaml"]) + expect(opts[0].hint).toBe("default") + }) + + it("offers Form / JSON / YAML for objects — Form is default", () => { + const opts = getViewOptions({a: 1}) + expect(opts.map((o) => o.value)).toEqual(["form", "json", "yaml"]) + expect(opts[0].hint).toBe("default") + }) + + it("offers Form / JSON / YAML for non-message arrays — Form is default", () => { + const opts = getViewOptions(["a", "b"]) + expect(opts.map((o) => o.value)).toEqual(["form", "json", "yaml"]) + expect(opts[0].hint).toBe("default") + }) + + it("offers Chat / JSON / YAML for role-tagged messages arrays — Chat is default", () => { + const opts = getViewOptions([ + {role: "user", content: "hi"}, + {role: "assistant", content: "hello"}, + ]) + expect(opts.map((o) => o.value)).toEqual(["chat", "json", "yaml"]) + expect(opts[0].hint).toBe("default") + }) + + it("always includes JSON and YAML as fallback options", () => { + for (const value of ["x", 42, true, null, {a: 1}, [1, 2]]) { + const values = getViewOptions(value).map((o) => o.value) + expect(values).toContain("json") + expect(values).toContain("yaml") + } + }) +}) + +describe("view-types: getDefaultViewForValue", () => { + it("returns the first option from getViewOptions for known kinds", () => { + expect(getDefaultViewForValue("hello")).toBe("text") + expect(getDefaultViewForValue(true)).toBe("text") + expect(getDefaultViewForValue({a: 1})).toBe("form") + expect(getDefaultViewForValue([1])).toBe("form") + expect(getDefaultViewForValue([{role: "user", content: "x"}])).toBe("chat") + }) + + it("treats undefined as a string-kind value (default → 'text')", () => { + expect(getDefaultViewForValue(undefined)).toBe("text") + }) +}) diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json index 4325e26920..69141dc923 100644 --- a/web/packages/agenta-entity-ui/package.json +++ b/web/packages/agenta-entity-ui/package.json @@ -19,6 +19,7 @@ "./selection": "./src/selection/index.ts", "./testcase": "./src/testcase/index.ts", "./variant": "./src/variant/index.ts", + "./view-types": "./src/view-types/index.ts", "./workflow": "./src/workflow/index.ts" }, "dependencies": { diff --git a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx new file mode 100644 index 0000000000..eee805f816 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx @@ -0,0 +1,457 @@ +/** + * FormView — recursive form rendering of an object value. + * + * Layout (per the design-mockups POC reference): + * - Vertical stack of form fields. Each field has a bold label on top and + * a body below. Fields separated by ~20px of vertical space. + * - Children of a nested object are indented behind a 2px light-gray + * vertical rail. + * - String leaf: a small card (1px gray border, 8px radius, white bg). + * A compact `View as ▾` dropdown sits in the field's label row, on the + * right. Below the card sits a `SharedEditor` with line numbers. + * - Number leaf: a single-line `InputNumber` with placeholder text. + * - Boolean leaf: a `Switch`. + * - Null leaf: italic muted "null". + * - Object / array: bold label, then a 2px gray left rail with the + * children stacked inside, indented ~16-20px. Children recurse. + * + * Color palette is intentionally minimal: white background, light-gray + * borders (#e5e7eb), dark labels, gray placeholders. No accent colors + * inside the form. Top-level kind chips live in the section header above. + * + * Promoted from the design-mockups POC (`ProposalV2FormView.tsx`). + */ + +import {useCallback, useMemo, useState, type ReactNode} from "react" + +import {SharedEditor} from "@agenta/ui/shared-editor" +import {Input, InputNumber, Switch, Tag} from "antd" +import {dump as yamlDump, load as yamlLoad} from "js-yaml" + +import { + detectNestedKind, + getDefaultViewForValue, + getViewOptions, + type NestedKind, + type ViewType, +} from "./viewTypes" +import {ViewTypeSelect} from "./ViewTypeSelect" + +const NESTED_KIND_LABEL: Record = { + string: "string", + number: "number", + boolean: "boolean", + null: "null", + object: "object", + array: "array", +} + +const NESTED_KIND_TONE: Record = { + string: "geekblue", + number: "blue", + boolean: "purple", + null: "default", + object: "gold", + array: "magenta", +} + +interface FormViewProps { + value: Record + onChange: (next: unknown) => void + editable?: boolean +} + +export function FormView({value, onChange, editable}: FormViewProps) { + // Wrap the entire form in a rail so the children visually read as + // "contents of the variable named in the section header above" — the + // rail is consistent with the rail that appears at deeper levels. + return ( +
+ onChange(next)} + depth={0} + editable={!!editable} + /> +
+ ) +} + +/* ── Recursive object rows ──────────────────────────────────────────── */ + +interface ObjectRowsProps { + obj: Record + depth: number + editable: boolean + onChange: (next: Record) => void +} + +function ObjectRows({obj, depth, editable, onChange}: ObjectRowsProps) { + const entries = Object.entries(obj) + if (entries.length === 0) { + return (empty object) + } + const updateKey = (key: string, next: unknown) => { + onChange({...obj, [key]: next}) + } + return ( +
+ {entries.map(([key, child]) => ( + updateKey(key, next)} + /> + ))} +
+ ) +} + +/* ── Single form field (label + body) ──────────────────────────────── */ + +interface FormFieldProps { + label: string + value: unknown + depth: number + editable: boolean + onChange: (next: unknown) => void +} + +function FormField({label, value, depth, editable, onChange}: FormFieldProps) { + const kind = detectNestedKind(value) + const labelStyle = depth === 0 ? styles.labelTop : styles.labelNested + + // For string fields we manage a per-field view mode (Text / Markdown / + // JSON / YAML). The view-type selector lives in the label row, on the + // right — same component the section header uses for the top-level + // variable, so the pattern is consistent across the surface. + const isString = kind === "string" + const stringOptions = useMemo(() => (isString ? getViewOptions(value) : []), [isString, value]) + const [stringMode, setStringMode] = useState(() => + isString ? getDefaultViewForValue(value) : "text", + ) + + return ( +
+
+
+ + + {NESTED_KIND_LABEL[kind]} + +
+ {isString ? ( + + ) : null} +
+
+ +
+
+ ) +} + +interface FieldBodyProps { + kind: NestedKind + value: unknown + depth: number + editable: boolean + onChange: (next: unknown) => void + /** For strings only — the active view mode chosen via the labelRow dropdown. */ + stringMode?: ViewType +} + +function FieldBody({ + kind, + value, + depth, + editable, + onChange, + stringMode, +}: FieldBodyProps): ReactNode { + if (kind === "object") { + return ( + + } + depth={depth + 1} + editable={editable} + onChange={(next) => onChange(next)} + /> + + ) + } + if (kind === "array") { + const arr = value as unknown[] + const updateIndex = (idx: number, next: unknown) => { + const copy = [...arr] + copy[idx] = next + onChange(copy) + } + return ( + +
+ {arr.map((item, idx) => ( + updateIndex(idx, next)} + /> + ))} +
+
+ ) + } + if (kind === "string") { + return ( + + ) + } + if (kind === "number") { + return ( + onChange(next ?? 0)} + placeholder="Enter number value" + style={styles.numberInput} + /> + ) + } + if (kind === "boolean") { + return ( + onChange(next)} + /> + ) + } + if (kind === "null") { + return ( + onChange(e.target.value)} + style={styles.input} + /> + ) + } + return null +} + +/* ── Nested rail (indent + 2px left border) ─────────────────────────── */ + +function NestedRail({children}: {children: ReactNode}) { + return
{children}
+} + +/* ── String leaf editor (no internal toolbar) ──────────────────────────── + The view-mode dropdown that used to live in this leaf's toolbar moved + to the field's labelRow (right side), to match the pattern the section + header uses for the top-level variable. The leaf now renders only the + editor inside a card. */ + +interface StringLeafEditorProps { + value: string + mode: ViewType + editable: boolean + onChange: (next: unknown) => void +} + +function StringLeafEditor({value, mode, editable, onChange}: StringLeafEditorProps) { + // For text/markdown we show the raw string; for json/yaml we try to + // parse the string as JSON and re-stringify into the target language, + // falling back to the raw string when the value isn't valid JSON. + const buffer = useMemo(() => { + if (mode === "text" || mode === "markdown") return value ?? "" + try { + const parsed = JSON.parse(value) + return mode === "json" + ? JSON.stringify(parsed, null, 2) + : yamlDump(parsed, {noCompatMode: true, lineWidth: 100}) + } catch { + return value ?? "" + } + }, [value, mode]) + + const handleChange = useCallback( + (next: string) => { + if (mode === "json") { + try { + const parsed = JSON.parse(next) + // The leaf is a string slot in the parent — serialize + // structured edits back into a JSON string. + onChange(JSON.stringify(parsed)) + } catch { + // ignore invalid JSON + } + return + } + if (mode === "yaml") { + try { + const parsed = yamlLoad(next) + onChange(JSON.stringify(parsed)) + } catch { + // ignore invalid YAML + } + return + } + onChange(next) + }, + [mode, onChange], + ) + + const isCode = mode === "json" || mode === "yaml" + + return ( +
+ +
+ ) +} + +/* ── Styles ─────────────────────────────────────────────────────────── */ + +const BORDER = "1px solid #e5e7eb" +const RAIL = "2px solid #e5e7eb" + +const styles = { + formOuter: { + // Always-on rail at the form root. The rail starts at the same + // left edge as the section header label (20px in from the section + // body), and the children sit indented behind it. Visually this + // says "everything below belongs to the variable named in the + // header above." + marginLeft: 20, + paddingLeft: 16, + paddingRight: 20, + borderLeft: RAIL, + }, + rootStack: { + display: "flex", + flexDirection: "column" as const, + // ~24px between top-level fields. + gap: 24, + }, + nestedStack: { + display: "flex", + flexDirection: "column" as const, + gap: 18, + }, + arrayStack: { + display: "flex", + flexDirection: "column" as const, + gap: 18, + }, + field: { + display: "flex", + flexDirection: "column" as const, + // Generous space between the label/kind chip row and the field body. + gap: 10, + }, + fieldBody: { + display: "block", + }, + labelRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + minWidth: 0, + }, + labelLeft: { + display: "flex", + alignItems: "center", + gap: 8, + minWidth: 0, + }, + kindTag: { + fontSize: 10, + marginInlineEnd: 0, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + }, + labelTop: { + fontSize: 14, + fontWeight: 600, + color: "#1f2937", + }, + labelNested: { + fontSize: 13, + fontWeight: 600, + color: "#1f2937", + }, + nestedRail: { + marginLeft: 4, + paddingLeft: 16, + borderLeft: RAIL, + }, + /* String leaf card — no toolbar; the dropdown lives in the field's + label row, matching the section header's pattern. */ + leafCard: { + background: "white", + border: BORDER, + borderRadius: 8, + overflow: "hidden", + padding: "6px 4px", + }, + /* Primitive inputs */ + input: { + fontSize: 13, + maxWidth: 480, + }, + numberInput: { + fontSize: 13, + width: 240, + }, + emptyHint: { + fontSize: 12, + color: "#9ca3af", + fontStyle: "italic" as const, + }, +} + +export default FormView diff --git a/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx new file mode 100644 index 0000000000..ba90795987 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx @@ -0,0 +1,112 @@ +/** + * ViewTypeSelect — the "View as ... ▾" dropdown that sits in each field + * header. Lets the user switch render mode (text/markdown/chat/form/json/yaml) + * for a single typed value. + * + * Visual style mirrors the role dropdown used inside the chat-message editor + * (`@agenta/ui/chat-message → SimpleDropdownSelect`): a borderless text button + * (label + caret) where the whole label-and-caret pair lights up on hover, + * and a click opens an antd Dropdown menu beneath it. The menu has a small + * "Select how to view" group label and exposes only the views that make + * sense for the field's current type (computed by `getViewOptions`). + * + * Promoted from the design-mockups POC. + */ + +import {useMemo} from "react" + +import {CaretDown} from "@phosphor-icons/react" +import {Button, Dropdown} from "antd" +import type {MenuProps} from "antd" + +import type {ViewOption, ViewType} from "./viewTypes" + +interface ViewTypeSelectProps { + value: ViewType + options: ViewOption[] + onChange: (value: ViewType) => void + disabled?: boolean +} + +const VIEW_LABELS: Record = { + text: "Text", + markdown: "Markdown", + chat: "Chat", + form: "Form", + json: "JSON", + yaml: "YAML", +} + +export function ViewTypeSelect({value, options, onChange, disabled}: ViewTypeSelectProps) { + const items: MenuProps["items"] = useMemo( + () => [ + { + key: "__lead", + type: "group", + label: Select how to view, + children: options.map((opt) => ({ + key: opt.value, + label: ( +
+ {opt.label} + {opt.hint ? {opt.hint} : null} +
+ ), + onClick: () => onChange(opt.value), + })), + }, + ], + [options, onChange], + ) + + return ( + + + + ) +} + +const styles = { + trigger: { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: "0 8px", + height: 24, + borderRadius: 4, + fontSize: 12, + color: "#051729", + }, + triggerLabel: {color: "rgba(5, 23, 41, 0.55)"}, + triggerValue: {color: "#051729", fontWeight: 600}, + triggerCaret: {marginTop: 1, opacity: 0.65}, + leadLabel: { + fontSize: 11, + fontWeight: 600, + color: "rgba(5, 23, 41, 0.55)", + textTransform: "uppercase" as const, + letterSpacing: "0.04em", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + }, + optionRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 16, + minWidth: 200, + }, + optionLabel: {fontSize: 13, fontWeight: 500, color: "#051729"}, + optionHint: {fontSize: 11, color: "rgba(5, 23, 41, 0.55)"}, +} + +export default ViewTypeSelect diff --git a/web/packages/agenta-entity-ui/src/view-types/formatters.ts b/web/packages/agenta-entity-ui/src/view-types/formatters.ts new file mode 100644 index 0000000000..330414f2da --- /dev/null +++ b/web/packages/agenta-entity-ui/src/view-types/formatters.ts @@ -0,0 +1,154 @@ +/** + * Pure value ↔ display conversions used by surfaces that compose the + * view-mode primitives (e.g. the playground inputs body). + * + * The runtime invariant: native JSON stays native until template rendering + * (RFC). Editors operate on strings; these helpers convert between the two + * per view mode and, on edit-back, preserve the original runtime type when + * possible (so transport sees a number/boolean/null when the user authored one). + * + * No React, no jotai, no antd — just functions over `unknown`. Unit-tested + * in `agenta-entities/tests/unit/playground-inputs-formatters.test.ts` + * (stopgap until entity-ui gets its own test runner). + */ + +import {inferLogicalType, type LogicalType} from "@agenta/shared/utils" +import {dump as yamlDump, load as yamlLoad} from "js-yaml" + +import type {ViewType} from "./viewTypes" + +/* ── Display: native value → string ──────────────────────────────────── */ + +/** + * Render a value as a string for display in an editor, per view mode. + * + * - text/markdown: primitives stringify naturally; objects/arrays show as + * compact JSON (matches the runtime's `{{var}}` rendering for whole-object + * insertion). + * - json: pretty-printed JSON (objects/arrays as object/array literal; strings + * that already contain JSON-shaped text get pretty-printed too). + * - yaml: YAML dump of the native value, falling back to raw string if the + * value isn't safely convertible. + * + * Returns `""` for `null` and `undefined` so the editor renders empty. + */ +export function valueToDisplay(value: unknown, mode: ViewType): string { + if (value === undefined || value === null) return "" + + if (mode === "text" || mode === "markdown") { + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + + if (mode === "json") { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) + return JSON.stringify(parsed, null, 2) + } catch { + return value + } + } + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } + } + + if (mode === "yaml") { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) + return yamlDump(parsed, {noCompatMode: true, lineWidth: 100}) + } catch { + return value + } + } + try { + return yamlDump(value, {noCompatMode: true, lineWidth: 100}) + } catch { + return String(value) + } + } + + // chat / form view modes are handled by dedicated widgets — this helper + // shouldn't be called for them. Defensive fallback: + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +/* ── Edit-back: string → native value, preserving original kind ──────── */ + +/** + * Coerce an edited string back into a native runtime value, preserving the + * original kind when possible. Used by text-mode edits where the editor only + * surfaces strings but the testcase needs to keep its native type. + * + * Rules: + * - originalType `"number"` → `Number(next)` if valid, else the raw string + * (empty string becomes the empty string, + * NOT 0, so the caller can treat it as "clear") + * - originalType `"boolean"` → `true` / `false` for canonical inputs, + * else the raw string (text-mode coercion is + * only relevant for paste edits — the actual + * widget is a Switch) + * - originalType `"null"` → `null` if `next` is empty, else the string + * - everything else → the raw string + * + * For json-object / json-array (which don't get text mode in V2's options), + * the helper still works defensively — but those modes route through + * `parseJsonEdit` or `parseYamlEdit` below, not through `coerceTextEdit`. + */ +export function coerceTextEdit(next: string, originalType: LogicalType): unknown { + if (originalType === "number") { + if (next === "") return "" + const n = Number(next) + return Number.isNaN(n) ? next : n + } + if (originalType === "boolean") { + if (next === "true") return true + if (next === "false") return false + return next + } + if (originalType === "null") { + return next === "" ? null : next + } + return next +} + +/** + * Parse the JSON-mode editor buffer back to a native value. Returns + * `{ok: true, value}` on success, `{ok: false}` on parse failure (caller + * keeps last valid value, mirrors the existing JSON-editor pattern). + */ +export function parseJsonEdit(next: string): {ok: true; value: unknown} | {ok: false} { + try { + return {ok: true, value: JSON.parse(next)} + } catch { + return {ok: false} + } +} + +/** + * Parse the YAML-mode editor buffer back to a native value. + */ +export function parseYamlEdit(next: string): {ok: true; value: unknown} | {ok: false} { + try { + return {ok: true, value: yamlLoad(next)} + } catch { + return {ok: false} + } +} + +/* ── Re-exports for callers that want one import site ─────────────── */ + +export {inferLogicalType, type LogicalType} diff --git a/web/packages/agenta-entity-ui/src/view-types/index.ts b/web/packages/agenta-entity-ui/src/view-types/index.ts new file mode 100644 index 0000000000..a16c4e2af2 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/view-types/index.ts @@ -0,0 +1,35 @@ +/** + * View-types subpath — render-mode vocabulary + components for the playground + * input UX and the testcase drawer. + * + * Import via: + * import {ViewTypeSelect, FormView, getViewOptions} from "@agenta/entity-ui/view-types" + * + * See `viewTypes.ts` for the conceptual model: type chip = inferred kind + * (granular, via `@agenta/shared` + `@agenta/ui` TypeChip); view-as dropdown + * = render mode (this module, 6-way). + */ + +export { + detectFieldKind, + detectNestedKind, + getDefaultViewForValue, + getViewOptions, + isChatMessagesArray, + type FieldKind, + type NestedKind, + type ViewOption, + type ViewType, +} from "./viewTypes" + +export {ViewTypeSelect} from "./ViewTypeSelect" +export {FormView} from "./FormView" + +export { + coerceTextEdit, + inferLogicalType, + parseJsonEdit, + parseYamlEdit, + valueToDisplay, + type LogicalType, +} from "./formatters" diff --git a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts new file mode 100644 index 0000000000..f11d1df01e --- /dev/null +++ b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts @@ -0,0 +1,129 @@ +/** + * Shared view-type vocabulary + per-value option logic for the playground + * input UX (and any other surface that needs a "view as ..." dropdown over + * a typed value). + * + * Promoted from the design-mockups POC (`ProposalV2Views.ts`). See: + * - `docs/design/prompt-runtime-unification/README.md` (WP-F1) + * - Approved design doc in `~/.gstack/projects/Agenta-AI-agenta/...` + * + * The 6 available view types: + * - text | unquoted plain text (string only) + * - markdown | rendered markdown (string only) + * - chat | chat-bubble rendering (messages-shaped arrays only) + * - form | labelled-form rendering (objects) + * - json | structured JSON in a code editor (always available) + * - yaml | structured YAML in a code editor (always available) + * + * `FieldKind` is the 4-way TOP-LEVEL bucketing used to compute the view-mode + * dropdown options for a given value. It is intentionally coarse: + * - string : strings + numbers + nulls (single-primitive values) + * - boolean : true / false + * - object : any structured value (object, non-message array) + * - chat : array of role-tagged message objects + * + * ⚠️ `FieldKind` is NOT the type-chip vocabulary. For the chip, use + * `inferLogicalType` from `@agenta/shared/utils` + `TypeChip` from + * `@agenta/ui`. The chip vocabulary is granular (string/number/boolean/null/ + * json-object/json-array) plus render-hint and state chips. Two distinct + * concerns, two distinct vocabularies. + */ + +/** A user-selectable rendering mode for a value. */ +export type ViewType = "text" | "markdown" | "chat" | "form" | "json" | "yaml" + +/** + * 4-way bucketing used to compute the view-mode dropdown options for a + * top-level value. Internal to the view-options decision. + * + * @see FieldKind doc comment in this file for why this is NOT the chip vocab. + */ +export type FieldKind = "string" | "boolean" | "object" | "chat" + +/** + * Whether the given top-level value should be treated as `chat` (an array of + * role-tagged message objects). Tool-calls and tool-responses still count. + */ +export function isChatMessagesArray(value: unknown): boolean { + if (!Array.isArray(value) || value.length === 0) return false + const VALID_ROLES = new Set(["system", "user", "assistant", "tool", "developer", "function"]) + return value.every((item) => { + if (!item || typeof item !== "object") return false + const role = (item as Record).role + return typeof role === "string" && VALID_ROLES.has(role) + }) +} + +/** + * Reduce the runtime type to the 4-way top-level vocabulary used to decide + * available view modes: + * - chat : array of role-tagged messages + * - object : any structured value (object, plain array) + * - boolean : true / false + * - string : everything else (string, number, null, undefined) + * + * Numbers and nulls are bucketed into `string` because they only appear at + * the top level as single primitives — no separate dropdown options needed. + */ +export function detectFieldKind(value: unknown): FieldKind { + if (isChatMessagesArray(value)) return "chat" + if (typeof value === "boolean") return "boolean" + if (Array.isArray(value)) return "object" + if (value !== null && typeof value === "object") return "object" + return "string" +} + +/** + * Inside a form / nested context we want the precise runtime type so the + * right widget renders (Switch for boolean, InputNumber for number, + * Input.TextArea for string, etc.). + */ +export type NestedKind = "string" | "number" | "boolean" | "null" | "object" | "array" + +export function detectNestedKind(value: unknown): NestedKind { + if (value === null) return "null" + if (typeof value === "string") return "string" + if (typeof value === "number") return "number" + if (typeof value === "boolean") return "boolean" + if (Array.isArray(value)) return "array" + if (typeof value === "object") return "object" + return "string" +} + +/** A dropdown option for the "View as ▾" select. */ +export interface ViewOption { + value: ViewType + label: string + /** Tiny right-aligned hint inside the dropdown row (e.g. "default", "raw"). */ + hint?: string +} + +/** + * Compute the dropdown options for a top-level field. Always includes JSON + + * YAML. Adds Text/Markdown for strings, Chat for chat arrays, Form for objects. + */ +export function getViewOptions(value: unknown): ViewOption[] { + const kind = detectFieldKind(value) + const opts: ViewOption[] = [] + + if (kind === "string") { + opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "markdown", label: "Markdown"}) + } else if (kind === "boolean") { + opts.push({value: "text", label: "Text", hint: "default"}) + } else if (kind === "chat") { + opts.push({value: "chat", label: "Chat", hint: "default"}) + } else if (kind === "object") { + opts.push({value: "form", label: "Form", hint: "default"}) + } + + opts.push({value: "json", label: "JSON"}) + opts.push({value: "yaml", label: "YAML"}) + + return opts +} + +/** Default view for a top-level field. */ +export function getDefaultViewForValue(value: unknown): ViewType { + return getViewOptions(value)[0]?.value ?? "json" +} From fbcb9e277715406242584eea11d3991f60b5f0f5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:04:06 +0200 Subject: [PATCH 03/41] feat(frontend): add PlaygroundInputsBody for V2-aligned input UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New component in @agenta/playground-ui/playground-inputs-body that renders a list of per-variable bordered cards composed from @agenta/entity-ui/view-types primitives. Replaces the per-variable SharedEditor rendering with type chips + per-variable "View as ▾" dropdown (text / markdown / chat / form / json / yaml). All edits write NATIVE values via onValueChange — never stringifies on the way out (RFC: "native JSON stays native until template rendering"). Components: - PlaygroundInputsBody: top-level orchestrator. Props: rowId, inputs, unreferencedColumns, editable, onValueChange, onAddDraftColumn, onViewModeChange. - VariableCard: single card with header (name + TypeChip via inferLogicalType + ViewTypeSelect + optional [draft] badge) and body switched by mode (SharedEditor / FormView / ChatMessageList / JSON / YAML code editor / InputNumber / Switch per type). - UnreferencedColumnsFooter: collapsed-by-default footer rendered once below all cards. "N unused testcase columns hidden..." - viewModeAtoms: atom family keyed by (rowId, varName) — session scoped per-variable view-mode state. Wiring into existing playground (SingleLayout / ComparisonLayout) lives in Step 6 — this commit ships the presentational component. --- .../agenta-playground-ui/package.json | 1 + .../PlaygroundInputsBody.tsx | 129 +++++++ .../UnreferencedColumnsFooter.tsx | 88 +++++ .../PlaygroundInputsBody/VariableCard.tsx | 345 ++++++++++++++++++ .../components/PlaygroundInputsBody/index.tsx | 31 ++ .../PlaygroundInputsBody/viewModeAtoms.ts | 34 ++ 6 files changed, 628 insertions(+) create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/UnreferencedColumnsFooter.tsx create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/viewModeAtoms.ts diff --git a/web/packages/agenta-playground-ui/package.json b/web/packages/agenta-playground-ui/package.json index dc76c13099..622709009d 100644 --- a/web/packages/agenta-playground-ui/package.json +++ b/web/packages/agenta-playground-ui/package.json @@ -28,6 +28,7 @@ "./comparison-view": "./src/components/ExecutionItemComparisonView/index.tsx", "./execution-items": "./src/components/ExecutionItems/index.tsx", "./execution-item-comparison-view": "./src/components/ExecutionItemComparisonView/index.tsx", + "./playground-inputs-body": "./src/components/PlaygroundInputsBody/index.tsx", "./workflow-revision-drawer": "./src/components/WorkflowRevisionDrawer/index.ts" }, "dependencies": { diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx new file mode 100644 index 0000000000..0781b5d54f --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx @@ -0,0 +1,129 @@ +/** + * PlaygroundInputsBody — the inputs panel inside one playground generation + * card. + * + * Replaces the per-variable SharedEditor cells the playground used to render + * via `VariableControlAdapter`. Instead, each variable gets its own bordered + * card with: + * - type chip (granular, via #4394's TypeChip + inferLogicalType) + * - "View as ▾" dropdown (text / markdown / chat / form / json / yaml, + * scoped to what makes sense for the value's kind) + * - per-view body (SharedEditor / FormView / ChatMessageList / etc.) + * + * The component is presentational + atom-aware (per-variable view mode is + * an atom family keyed by `(rowId, varName)` — see `viewModeAtoms.ts`). All + * write-backs flow as NATIVE values via `onValueChange`. No stringification + * on the way out (RFC: "native JSON stays native until template rendering"). + * + * Designed to be wired into the existing OSS playground in Step 6 — this + * branch ships the component and its primitives; Step 6 swaps the current + * `VariableControlAdapter` rendering for ``. + * + * Visibility rule (computed by the parent, passed in here): + * - `inputs`: referenced variables, including draft ones. + * Each gets an expanded card. + * - `unreferencedColumns`: testcase columns the prompt doesn't reference. + * Collapsed under a single footer row. + * + * The parent owns the prompt-text → referenced-variables computation (lives + * in OSS / agenta-playground state), so this component stays pure. + */ + +import type {ViewType} from "@agenta/entity-ui/view-types" +import {getDefaultViewForValue, getViewOptions} from "@agenta/entity-ui/view-types" + +import {UnreferencedColumnsFooter} from "./UnreferencedColumnsFooter" +import {VariableCard} from "./VariableCard" + +export interface PlaygroundInputsBodyVariable { + /** Variable name (testcase column or template-referenced variable). */ + name: string + /** Native value, or `undefined` for draft variables. */ + value: unknown + /** True when the variable is referenced by the prompt but not authored + * on the testcase yet. Renders a `[draft]` badge. */ + isDraft?: boolean +} + +export interface PlaygroundInputsBodyProps { + /** Stable identifier for the playground generation row this card lives + * in. Used to key per-variable view-mode atoms — must be stable across + * testcase column adds (so draft variables don't lose their selected + * mode when the column gets persisted). */ + rowId: string + /** Variables referenced by the prompt chain. Rendered as expanded cards + * in order. Include draft variables (referenced but not on testcase) + * with `isDraft: true`. */ + inputs: PlaygroundInputsBodyVariable[] + /** Testcase columns NOT referenced by the prompt chain. Rendered under + * a single collapsed footer below all variable cards. Pass `undefined` + * or `[]` to skip the footer entirely. */ + unreferencedColumns?: PlaygroundInputsBodyVariable[] + /** Whether referenced variable cards are editable. */ + editable: boolean + /** Writes the new value for an existing column to the testcase store. + * Implementation should route through `testcaseMolecule.actions.update` + * so the testcase entity is updated atomically. NATIVE value — no + * stringification by the caller. */ + onValueChange: (name: string, value: unknown) => void + /** Optional. Creates a new testcase column on first edit of a draft + * variable. If undefined, draft edits route through `onValueChange` + * and the caller decides how to persist. */ + onAddDraftColumn?: (name: string, value: unknown) => void + /** Optional. Notified when the user changes the view mode for a card. */ + onViewModeChange?: (name: string, mode: ViewType) => void + /** Optional. When `unreferencedColumns` is shown and the footer is + * expanded, gate edits to those rows. Defaults to read-only. */ + unreferencedEditable?: boolean +} + +export function PlaygroundInputsBody({ + rowId, + inputs, + unreferencedColumns, + editable, + onValueChange, + onAddDraftColumn, + onViewModeChange, + unreferencedEditable = false, +}: PlaygroundInputsBodyProps) { + const handleValueChange = (name: string, value: unknown) => { + const variable = inputs.find((v) => v.name === name) + if (variable?.isDraft && onAddDraftColumn) { + onAddDraftColumn(name, value) + } else { + onValueChange(name, value) + } + } + + return ( +
+ {inputs.map((variable) => ( + + ))} + {unreferencedColumns && unreferencedColumns.length > 0 ? ( + ({name: c.name, value: c.value}))} + editable={unreferencedEditable} + onValueChange={ + unreferencedEditable + ? (name, value) => onValueChange(name, value) + : undefined + } + /> + ) : null} +
+ ) +} diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/UnreferencedColumnsFooter.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/UnreferencedColumnsFooter.tsx new file mode 100644 index 0000000000..18defc6f1d --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/UnreferencedColumnsFooter.tsx @@ -0,0 +1,88 @@ +/** + * UnreferencedColumnsFooter — collapsed-by-default footer rendered once + * below all variable cards. + * + * "N unused testcase columns hidden because the prompt does not reference + * them." Click to expand and render the unreferenced columns as collapsed + * read-only cards beneath. + * + * Rendered ONCE per generation card (not per variable). When the prompt + * adds a reference to a previously-unused column, the parent moves that + * entry from `unreferencedColumns` to `inputs` and the footer count drops + * by one — no special handling here. + */ + +import {useState} from "react" + +import {getDefaultViewForValue, getViewOptions} from "@agenta/entity-ui/view-types" +import {CaretDown, CaretRight} from "@phosphor-icons/react" +import {Button} from "antd" + +import {VariableCard} from "./VariableCard" + +interface UnreferencedColumn { + name: string + value: unknown +} + +interface UnreferencedColumnsFooterProps { + /** Stable identifier for the generation row this footer lives in. */ + rowId: string + columns: UnreferencedColumn[] + /** Whether expanded cards are editable. Most surfaces will pass `false` + * here — unused columns shouldn't tempt the user into editing them. + * Set `true` if the parent wants edits to be allowed once revealed. */ + editable?: boolean + /** Fires when the user edits a previously-unused column. Most callers + * will leave this undefined (read-only). */ + onValueChange?: (name: string, value: unknown) => void +} + +export function UnreferencedColumnsFooter({ + rowId, + columns, + editable = false, + onValueChange, +}: UnreferencedColumnsFooterProps) { + const [expanded, setExpanded] = useState(false) + + if (columns.length === 0) return null + + const summary = `${columns.length} unused testcase column${columns.length === 1 ? "" : "s"} hidden because the prompt does not reference them.` + + return ( +
+ + {expanded ? ( +
+ {columns.map((col) => ( + { + /* no-op when parent didn't supply a handler */ + }) + } + /> + ))} +
+ ) : null} +
+ ) +} diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx new file mode 100644 index 0000000000..100a3e5825 --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -0,0 +1,345 @@ +/** + * VariableCard — a single bordered input card for one playground variable. + * + * Header (single line): + * - Left: variable name (mono, blue), TypeChip (inferLogicalType + + * chat-detection override), optional [draft] badge. + * - Right: ViewTypeSelect (the "View as ▾" dropdown — Text/Markdown/Chat/ + * Form/JSON/YAML, options vary per kind). + * + * Body switches by the active view mode: + * - text → Text editor (string), antd InputNumber (number), Switch + * (boolean), "null" placeholder (null) + * - markdown → SharedEditor with markdownView enabled + * - chat → ChatMessageList over a messages array + * - form → FormView (recursive object/array editor) + * - json → SharedEditor (codeOnly language="json"), parse-on-edit + * - yaml → SharedEditor (codeOnly language="yaml"), parse-on-edit + * + * All edits write NATIVE values via `onValueChange(name, value)` — the card + * never stringifies on the way out (RFC: "native JSON stays native until + * template rendering"). The runtime gets objects as objects, arrays as + * arrays, numbers as numbers, etc. + */ + +import {useCallback, useMemo, useState, type ReactNode} from "react" + +import { + FormView, + ViewTypeSelect, + coerceTextEdit, + inferLogicalType, + isChatMessagesArray, + parseJsonEdit, + parseYamlEdit, + valueToDisplay, +} from "@agenta/entity-ui/view-types" +import type {LogicalType, ViewOption, ViewType} from "@agenta/entity-ui/view-types" +import {ChatMessageList} from "@agenta/ui/chat-message" +import type {SimpleChatMessage} from "@agenta/ui/chat-message" +import {SharedEditor} from "@agenta/ui/shared-editor" +import {TypeChip} from "@agenta/ui/type-chip" +import type {ChipVariant} from "@agenta/ui/type-chip" +import {InputNumber, Switch, Tag, Typography} from "antd" +import clsx from "clsx" +import {useAtom} from "jotai" + +import {variableViewModeAtomFamily} from "./viewModeAtoms" + +const {Text: AntText} = Typography + +interface VariableCardProps { + /** Stable identifier for the generation row this variable lives in. */ + rowId: string + /** Variable name (testcase column or referenced template variable). */ + name: string + /** Native value, or `undefined` for a draft variable. */ + value: unknown + /** Computed dropdown options for the value. Provided by the parent so we + * recompute consistently with how the parent decided which cards to + * render (e.g. for chat-shaped messages → Chat is offered). */ + options: ViewOption[] + /** The default view mode for this value. Used when the user hasn't + * explicitly chosen one yet (atom value is `null`). */ + defaultMode: ViewType + /** True when the variable is referenced by the prompt but not authored + * on the testcase yet. Renders a `[draft]` badge. */ + isDraft?: boolean + /** Whether the card is editable (vs read-only). */ + editable: boolean + /** Writes the new value to the testcase / draft store. NATIVE value. */ + onValueChange: (name: string, value: unknown) => void + /** Notified when the user picks a new view mode (optional — only the + * atom family is the source of truth; parents can subscribe here for + * side effects like analytics). */ + onViewModeChange?: (name: string, mode: ViewType) => void +} + +export function VariableCard({ + rowId, + name, + value, + options, + defaultMode, + isDraft, + editable, + onValueChange, + onViewModeChange, +}: VariableCardProps) { + const [explicitMode, setExplicitMode] = useAtom( + variableViewModeAtomFamily({rowId, varName: name}), + ) + const mode: ViewType = explicitMode ?? defaultMode + + const handleModeChange = useCallback( + (next: ViewType) => { + setExplicitMode(next) + onViewModeChange?.(name, next) + }, + [setExplicitMode, onViewModeChange, name], + ) + + const handleValueChange = useCallback( + (next: unknown) => onValueChange(name, next), + [onValueChange, name], + ) + + const chipVariant = useMemo( + () => (isChatMessagesArray(value) ? "messages" : (inferLogicalType(value) as ChipVariant)), + [value], + ) + + return ( +
+
+
+ + {name} + + + {isDraft ? ( + + draft + + ) : null} +
+ +
+
+ +
+
+ ) +} + +/* ── Body switcher ──────────────────────────────────────────────────── */ + +interface CardBodyProps { + mode: ViewType + value: unknown + editable: boolean + onChange: (next: unknown) => void +} + +function CardBody({mode, value, editable, onChange}: CardBodyProps): ReactNode { + const originalType = useMemo(() => inferLogicalType(value), [value]) + + if (mode === "form") { + // FormView expects an object record. If the value is an array, wrap + // its indexed children into a record { "0": ..., "1": ... } so the + // form can render. FormView itself recurses into arrays as well, + // but its root signature is `Record`. + const obj = + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : Array.isArray(value) + ? Object.fromEntries(value.map((v, i) => [String(i), v])) + : {} + return ( + { + if (Array.isArray(value)) { + // Recover an array from the indexed-record form. Sort + // the keys numerically and discard non-numeric keys + // (defensive — FormView preserves keys 1:1). + const rec = next as Record + const arr: unknown[] = [] + for (const [k, v] of Object.entries(rec)) { + const idx = Number(k) + if (Number.isInteger(idx) && idx >= 0) { + arr[idx] = v + } + } + onChange(arr) + } else { + onChange(next) + } + }} + /> + ) + } + + if (mode === "chat") { + const messages = isChatMessagesArray(value) ? (value as SimpleChatMessage[]) : [] + return ( + onChange(next)} + disabled={!editable} + /> + ) + } + + if (mode === "json" || mode === "yaml") { + return ( + + ) + } + + // text / markdown for primitives — use the right widget per actual type. + if (originalType === "number" && mode === "text") { + return ( + onChange(next ?? null)} + placeholder="Enter number" + className="w-full max-w-[320px]" + /> + ) + } + + if (originalType === "boolean" && mode === "text") { + return ( + onChange(next)} + /> + ) + } + + // string + null fall through to a SharedEditor (also covers markdown). + return ( + + ) +} + +/* ── Text / Markdown editor ─────────────────────────────────────────── */ + +interface TextLeafEditorProps { + mode: ViewType // "text" | "markdown" only + value: unknown + editable: boolean + originalType: LogicalType + onChange: (next: unknown) => void +} + +function TextLeafEditor({mode, value, editable, originalType, onChange}: TextLeafEditorProps) { + const initial = useMemo(() => valueToDisplay(value, mode), [value, mode]) + const [buffer, setBuffer] = useState(initial) + + const handleChange = useCallback( + (next: string) => { + setBuffer(next) + onChange(coerceTextEdit(next, originalType)) + }, + [originalType, onChange], + ) + + return ( + + ) +} + +/* ── JSON / YAML code editor ────────────────────────────────────────── */ + +interface CodeLeafEditorProps { + mode: "json" | "yaml" + value: unknown + editable: boolean + onChange: (next: unknown) => void +} + +function CodeLeafEditor({mode, value, editable, onChange}: CodeLeafEditorProps) { + const initial = useMemo(() => valueToDisplay(value, mode), [value, mode]) + const [buffer, setBuffer] = useState(initial) + + const handleChange = useCallback( + (next: string) => { + setBuffer(next) + const result = mode === "json" ? parseJsonEdit(next) : parseYamlEdit(next) + if (result.ok) onChange(result.value) + // Invalid → keep local buffer; don't propagate (matches V2 + the + // existing JsonVariableEditor pattern in VariableControlAdapter). + }, + [mode, onChange], + ) + + return ( + + ) +} diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx new file mode 100644 index 0000000000..833d41ae28 --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx @@ -0,0 +1,31 @@ +/** + * `@agenta/playground-ui/playground-inputs-body` — public exports. + * + * import {PlaygroundInputsBody} from "@agenta/playground-ui/playground-inputs-body" + * + * Used by the OSS playground (Step 6) to replace the current per-variable + * `VariableControlAdapter` rendering with type-aware cards composed from + * `@agenta/entity-ui/view-types` primitives. See the approved design doc + * (`~/.gstack/projects/Agenta-AI-agenta/...-playground-mustache-input-ux-*`). + */ + +export {PlaygroundInputsBody} from "./PlaygroundInputsBody" +export type {PlaygroundInputsBodyProps, PlaygroundInputsBodyVariable} from "./PlaygroundInputsBody" + +export {VariableCard} from "./VariableCard" +export {UnreferencedColumnsFooter} from "./UnreferencedColumnsFooter" + +export {variableViewModeAtomFamily} from "./viewModeAtoms" +export type {VariableViewModeKey} from "./viewModeAtoms" + +// Convenience re-exports — same symbols are also available directly from +// `@agenta/entity-ui/view-types`. Surfaced here so consumers integrating +// the playground inputs body have a single import site. +export { + coerceTextEdit, + inferLogicalType, + parseJsonEdit, + parseYamlEdit, + valueToDisplay, + type LogicalType, +} from "@agenta/entity-ui/view-types" diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/viewModeAtoms.ts b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/viewModeAtoms.ts new file mode 100644 index 0000000000..46ed17d084 --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/viewModeAtoms.ts @@ -0,0 +1,34 @@ +/** + * Per-variable view-mode state for `PlaygroundInputsBody`. + * + * One atom per (generation row, variable name). The atom holds either an + * explicitly chosen `ViewType` (text/markdown/chat/form/json/yaml) or `null` + * to mean "use the default for this value's kind" (computed at render via + * `getDefaultViewForValue`). + * + * Session-scoped on purpose: a fresh atom family per app session. If users + * later ask for persistence across reloads, swap the inner `atom(null)` for + * `atomWithStorage` keyed by `(appId, varName)` — that's the explicit + * follow-up tracked in the design doc. + * + * The family key uses `generationRowId` rather than `testcaseId` because + * draft variables (referenced by the prompt but absent from the testcase) + * don't yet have a stable testcase column ID. Generation rows are stable for + * both authored and draft variables. + */ + +import type {ViewType} from "@agenta/entity-ui/view-types" +import {atom} from "jotai" +import {atomFamily} from "jotai-family" + +export interface VariableViewModeKey { + /** Stable identifier for the playground generation row this variable lives in. */ + rowId: string + /** Variable name (the testcase column or referenced template variable). */ + varName: string +} + +export const variableViewModeAtomFamily = atomFamily( + (_key: VariableViewModeKey) => atom(null), + (a, b) => a.rowId === b.rowId && a.varName === b.varName, +) From 5144ccede8970c1c4fc2ebaa61d33cf0ff2972bb Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:04:45 +0200 Subject: [PATCH 04/41] feat(frontend): add inputs visibility rule for the playground inputs body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new V2-aligned input UX renders one card per variable that the prompt references — including draft variables (referenced but not yet on the testcase). Testcase columns the prompt does NOT reference are collapsed under an "N unused testcase columns" footer. This commit adds the pure split helper + the atom layer that feeds it: - splitInputsVisibility({referencedKeys, testcaseData}) → {inputs, unreferencedColumns}. Pure function in execution/visibility.ts. inputs[i].isDraft = true when name is referenced but missing from testcaseData. - referencedVariableKeysAtomFamily(downstreamKey): schema-referenced variable keys only (template input ports + downstream evaluator expected columns). Excludes testcase-only extras. - rowVariableKeysAtomFamily refactored to call referencedVariableKeys + add testcase-extras on top. Same external contract — connected testset still merges expected_output and friends. - playgroundInputsAtomFamily({testcaseId, downstreamKey}): wraps the pure split with the live atom sources. System fields stripped from testcase data before splitting so __id__ and friends don't bleed into the unused-columns footer. - executionItemController.selectors.inputsVisibility(...) + .referencedVariableKeys(...) expose the new atoms on the controller surface — matches PlaygroundInputsBody's props shape directly. 13 new tests pin the contract: referenced+testcase intersection (native preservation), draft annotation (referenced - testcase), null/undefined value handling, unreferenced collection, edge cases. --- .../unit/playground-inputs-visibility.test.ts | 166 ++++++++++++++++++ .../controllers/executionItemController.ts | 31 ++++ .../src/state/execution/index.ts | 10 ++ .../src/state/execution/selectors.ts | 116 +++++++++--- .../src/state/execution/visibility.ts | 78 ++++++++ 5 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/playground-inputs-visibility.test.ts create mode 100644 web/packages/agenta-playground/src/state/execution/visibility.ts diff --git a/web/packages/agenta-entities/tests/unit/playground-inputs-visibility.test.ts b/web/packages/agenta-entities/tests/unit/playground-inputs-visibility.test.ts new file mode 100644 index 0000000000..036c5c781e --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/playground-inputs-visibility.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for the pure `splitInputsVisibility` rule that powers Step 4 + * of the playground mustache + input UX branch. + * + * Same stopgap-location reasoning as the other Step-2/3 tests: vitest lives + * in agenta-entities; the helper lives in @agenta/playground (the + * package's own test runner is a follow-up). Cross-package relative import + * below is a test-time dep only. + * + * TODO(follow-up): Move alongside the helper once @agenta/playground gets + * its own vitest runner. + */ +import {describe, expect, it} from "vitest" + +import {splitInputsVisibility} from "../../../agenta-playground/src/state/execution/visibility" + +describe("splitInputsVisibility — referenced vs draft vs unreferenced", () => { + describe("referenced + testcase intersection", () => { + it("returns inputs in referencedKeys order with their testcase values", () => { + const result = splitInputsVisibility({ + referencedKeys: ["country", "geo", "messages"], + testcaseData: { + geo: {region: "Pacific"}, + messages: [{role: "user", content: "hi"}], + country: "Vanuatu", + }, + }) + + expect(result.inputs.map((i) => i.name)).toEqual(["country", "geo", "messages"]) + expect(result.inputs[0].value).toBe("Vanuatu") + expect(result.inputs[1].value).toEqual({region: "Pacific"}) + expect(result.inputs[2].value).toEqual([{role: "user", content: "hi"}]) + expect(result.inputs.every((i) => !i.isDraft)).toBe(true) + }) + + it("preserves native object/array values by reference (no clone, no stringify)", () => { + const geo = {region: "Pacific"} + const result = splitInputsVisibility({ + referencedKeys: ["geo"], + testcaseData: {geo}, + }) + expect(result.inputs[0].value).toBe(geo) + }) + }) + + describe("draft variables (referenced but absent from testcase)", () => { + it("annotates referenced names missing from testcase as isDraft: true", () => { + const result = splitInputsVisibility({ + referencedKeys: ["country", "iso_code"], + testcaseData: {country: "Vanuatu"}, + }) + + expect(result.inputs).toEqual([ + {name: "country", value: "Vanuatu"}, + {name: "iso_code", value: undefined, isDraft: true}, + ]) + }) + + it("keeps draft order with referenced order (no reshuffling)", () => { + const result = splitInputsVisibility({ + referencedKeys: ["a", "b", "c"], + testcaseData: {b: 2}, + }) + expect(result.inputs.map((i) => `${i.name}:${i.isDraft ?? false}`)).toEqual([ + "a:true", + "b:false", + "c:true", + ]) + }) + + it("treats a key present with a null value as NOT draft (null is a real value)", () => { + const result = splitInputsVisibility({ + referencedKeys: ["x"], + testcaseData: {x: null}, + }) + expect(result.inputs[0]).toEqual({name: "x", value: null}) + expect(result.inputs[0].isDraft).toBeUndefined() + }) + + it("treats a key present with `undefined` as NOT draft (it's authored)", () => { + // Distinction matters: a testcase row that explicitly carries + // the key (even as undefined) is "authored, just empty". The + // draft state is for keys MISSING from the row entirely. + const result = splitInputsVisibility({ + referencedKeys: ["x"], + testcaseData: {x: undefined}, + }) + expect(result.inputs[0]).toEqual({name: "x", value: undefined}) + expect(result.inputs[0].isDraft).toBeUndefined() + }) + }) + + describe("unreferenced columns", () => { + it("collects testcase columns not in referenced", () => { + const result = splitInputsVisibility({ + referencedKeys: ["country"], + testcaseData: {country: "Vanuatu", population: 320, notes: "n/a"}, + }) + + expect(result.unreferencedColumns).toEqual([ + {name: "population", value: 320}, + {name: "notes", value: "n/a"}, + ]) + }) + + it("preserves native value types in unreferenced (no stringify)", () => { + const profile = {name: "Ada"} + const result = splitInputsVisibility({ + referencedKeys: [], + testcaseData: {profile, tags: ["a", "b"]}, + }) + + expect(result.unreferencedColumns[0].value).toBe(profile) + expect(result.unreferencedColumns[1].value).toEqual(["a", "b"]) + }) + + it("returns empty unreferenced when every testcase key is referenced", () => { + const result = splitInputsVisibility({ + referencedKeys: ["a", "b"], + testcaseData: {a: 1, b: 2}, + }) + expect(result.unreferencedColumns).toEqual([]) + }) + }) + + describe("edge cases", () => { + it("empty referenced + empty testcase → empty result", () => { + expect(splitInputsVisibility({referencedKeys: [], testcaseData: {}})).toEqual({ + inputs: [], + unreferencedColumns: [], + }) + }) + + it("empty referenced + non-empty testcase → all rows go to unreferenced", () => { + const result = splitInputsVisibility({ + referencedKeys: [], + testcaseData: {a: 1, b: 2}, + }) + expect(result.inputs).toEqual([]) + expect(result.unreferencedColumns).toEqual([ + {name: "a", value: 1}, + {name: "b", value: 2}, + ]) + }) + + it("non-empty referenced + empty testcase → all referenced are drafts", () => { + const result = splitInputsVisibility({ + referencedKeys: ["x", "y"], + testcaseData: {}, + }) + expect(result.inputs).toEqual([ + {name: "x", value: undefined, isDraft: true}, + {name: "y", value: undefined, isDraft: true}, + ]) + expect(result.unreferencedColumns).toEqual([]) + }) + + it("does NOT mutate the inputs (referenced list and testcase data are untouched)", () => { + const refs = ["a", "b"] + const data = {a: 1, c: 3} + splitInputsVisibility({referencedKeys: refs, testcaseData: data}) + expect(refs).toEqual(["a", "b"]) + expect(data).toEqual({a: 1, c: 3}) + }) + }) +}) diff --git a/web/packages/agenta-playground/src/state/controllers/executionItemController.ts b/web/packages/agenta-playground/src/state/controllers/executionItemController.ts index 722fe40184..55172d2543 100644 --- a/web/packages/agenta-playground/src/state/controllers/executionItemController.ts +++ b/web/packages/agenta-playground/src/state/controllers/executionItemController.ts @@ -93,6 +93,9 @@ import { setTestcaseCellValueAtom, downstreamNodeQueriesAtom, rowVariableKeysAtomFamily, + referencedVariableKeysAtomFamily, + playgroundInputsAtomFamily, + type PlaygroundInputsAtomKey, } from "../execution" import {buildAssistantMessage} from "../helpers/messageFactory" @@ -207,6 +210,34 @@ export const executionItemController = { /** Direct testcase entity data — full data record */ testcaseData: (testcaseId: string) => testcaseDataAtomFamily(testcaseId), + /** + * Schema-referenced variable keys for the current row. + * + * Distinct from `variableKeys`/`variableKeysForDownstream` — those + * include testcase-only extras (e.g. `expected_output`) merged in + * for connected testsets. This one returns ONLY what the prompt + * template + downstream evaluators reference. Used to drive the + * referenced-vs-unreferenced split in `inputsVisibility` below. + */ + referencedVariableKeys: (downstreamKey: string) => + referencedVariableKeysAtomFamily(downstreamKey), + + /** + * Playground inputs visibility: the three-way split that the new + * PlaygroundInputsBody consumes. + * + * Returns `{inputs, unreferencedColumns}`: + * - `inputs` : referenced variables, each with its + * testcase value (or `isDraft: true` + * when referenced but absent). + * - `unreferencedColumns` : testcase columns the prompt does NOT + * reference (collapsed under footer). + * + * Wraps `referencedVariableKeys` + `testcaseMolecule.atoms.data` + * with the pure `splitInputsVisibility` rule. + */ + inputsVisibility: (params: PlaygroundInputsAtomKey) => playgroundInputsAtomFamily(params), + // ---------------------------------------------------------------- // Per-item messages (chat mode) // ---------------------------------------------------------------- diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index 2652b1f47c..dcae246899 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -218,6 +218,9 @@ export { rowVariableValueAtomFamily, rowVariableKeysWithContextAtom, rowVariableKeysAtomFamily, + referencedVariableKeysAtomFamily, + playgroundInputsAtomFamily, + type PlaygroundInputsAtomKey, downstreamNodeQueriesAtom, activeSessionsWithContextAtom, isCompareModeWithContextAtom, @@ -274,6 +277,13 @@ export type {RenderableExecutionItem, RenderableExecutionRow} from "./selectors" export {executeStepForSessionWithExecutionItems} from "./executionRunner" +// ============================================================================ +// VISIBILITY (pure split helper for playground inputs body) +// ============================================================================ + +export {splitInputsVisibility} from "./visibility" +export type {InputsVisibility, SplitInputsVisibilityArgs, VariableEntry} from "./visibility" + // ============================================================================ // GENERATION SELECTORS (higher-level UI selectors) // ============================================================================ diff --git a/web/packages/agenta-playground/src/state/execution/selectors.ts b/web/packages/agenta-playground/src/state/execution/selectors.ts index 070755f588..ad0606b060 100644 --- a/web/packages/agenta-playground/src/state/execution/selectors.ts +++ b/web/packages/agenta-playground/src/state/execution/selectors.ts @@ -36,6 +36,7 @@ import { import {displayedEntityIdsAtom} from "./displayedEntities" import {createExecutionItemHandle, type ExecutionItemLifecycleSnapshot} from "./executionItems" import type {RunStatus} from "./types" +import {splitInputsVisibility} from "./visibility" /** * UI-display helper. Converts a cell value to a string for rendering inside @@ -309,36 +310,30 @@ const evaluatorExpectedColumnsAtom = atom((get) => { * a Jotai issue where module-level derived atoms don't re-evaluate when * playgroundNodesAtom changes in disconnect→reconnect flows. */ -const rowVariableKeysAtomFamily = atomFamily((downstreamKey: string) => +/** + * Schema-referenced variable keys: prompt template input ports + downstream + * evaluator expected columns. Excludes testcase-only extras. + * + * This is the "what does the prompt actually reference" view used by the + * variable-visibility rule (`playgroundInputsAtomFamily` below): referenced + * keys feed expanded variable cards; testcase columns NOT in this set get + * collapsed under the "unused columns" footer. + * + * `rowVariableKeysAtomFamily` (below) wraps this with the testcase-extras + * merge for callers that want the full "what's available" view. + */ +const referencedVariableKeysAtomFamily = atomFamily((downstreamKey: string) => atom((get) => { const loadableId = get(derivedLoadableIdAtom) if (!loadableId) return [] const isChat = get(isChatModeAtom) === true const columns = get(loadableController.selectors.columns(loadableId)) const primaryKeys = columns.map((column) => column.key) - const evaluatorKeys = get(evaluatorExpectedColumnsAtom) - // In connected mode (testset loaded), include extra columns from - // testcase entity data that aren't in the template (e.g. "expected_output"). - // In local mode, the template's input ports are the sole authority — - // testcase data may contain stale keys from a previous template or app. - const mode = get(loadableController.selectors.mode(loadableId)) - const testcaseKeys: string[] = [] - if (mode === "connected") { - const testcaseColumns = get(testcaseMolecule.atoms.columns) as {key: string}[] - if (testcaseColumns) { - for (const c of testcaseColumns) { - if (!isSystemField(c.key)) { - testcaseKeys.push(c.key) - } - } - } - } - const keySet = new Set(primaryKeys) const merged = [...primaryKeys] - for (const key of [...evaluatorKeys, ...testcaseKeys]) { + for (const key of evaluatorKeys) { if (key && !keySet.has(key)) { keySet.add(key) merged.push(key) @@ -349,6 +344,34 @@ const rowVariableKeysAtomFamily = atomFamily((downstreamKey: string) => }), ) +const rowVariableKeysAtomFamily = atomFamily((downstreamKey: string) => + atom((get) => { + const loadableId = get(derivedLoadableIdAtom) + if (!loadableId) return [] + const referencedKeys = get(referencedVariableKeysAtomFamily(downstreamKey)) + + // In connected mode (testset loaded), include extra columns from + // testcase entity data that aren't in the template (e.g. "expected_output"). + // In local mode, the template's input ports are the sole authority — + // testcase data may contain stale keys from a previous template or app. + const mode = get(loadableController.selectors.mode(loadableId)) + if (mode !== "connected") return referencedKeys + + const testcaseColumns = get(testcaseMolecule.atoms.columns) as {key: string}[] + if (!testcaseColumns) return referencedKeys + + const keySet = new Set(referencedKeys) + const merged = [...referencedKeys] + for (const c of testcaseColumns) { + if (!isSystemField(c.key) && !keySet.has(c.key)) { + keySet.add(c.key) + merged.push(c.key) + } + } + return merged + }), +) + /** * Backward-compatible selector that reads from the atomFamily with empty key. * For components that also subscribe to playgroundNodesAtom directly (e.g. SingleLayout), @@ -357,7 +380,58 @@ const rowVariableKeysAtomFamily = atomFamily((downstreamKey: string) => */ export const rowVariableKeysWithContextAtom = rowVariableKeysAtomFamily("") -export {rowVariableKeysAtomFamily} +export {referencedVariableKeysAtomFamily, rowVariableKeysAtomFamily} + +// ============================================================================ +// PLAYGROUND INPUTS VISIBILITY (Step 4 of the playground mustache + input UX) +// ============================================================================ + +/** + * The visibility view that `PlaygroundInputsBody` consumes: referenced + * variables (with draft annotation for those missing from the testcase) + + * unreferenced testcase columns. + * + * Reactivity: + * referencedVariableKeysAtomFamily(downstreamKey) + * │ + * ├──▶ playgroundInputsAtomFamily({testcaseId, downstreamKey}) + * │ = atom((get) => splitInputsVisibility({ + * │ referencedKeys: get(referenced...), + * │ testcaseData: get(testcaseMolecule.atoms.data), + * │ })) + * │ + * testcaseMolecule.atoms.data(testcaseId) + * + * System fields (`__id__`, etc.) are stripped from `testcaseData` before + * the split so they don't bleed into the unused-columns footer. + */ +export interface PlaygroundInputsAtomKey { + testcaseId: string + /** Downstream-evaluator context key — pass the same string the caller + * uses with `rowVariableKeysAtomFamily` (`""` is the default). */ + downstreamKey?: string +} + +export const playgroundInputsAtomFamily = atomFamily( + ({testcaseId, downstreamKey = ""}: PlaygroundInputsAtomKey) => + atom((get) => { + const referencedKeys = get(referencedVariableKeysAtomFamily(downstreamKey)) + const raw = + (get(testcaseMolecule.atoms.data(testcaseId)) as Record | null) ?? + {} + + // Strip system fields up-front so the unused-columns footer + // doesn't expose `__id__` and friends to the user. + const testcaseData: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (isSystemField(key)) continue + testcaseData[key] = value + } + + return splitInputsVisibility({referencedKeys, testcaseData}) + }), + (a, b) => a.testcaseId === b.testcaseId && (a.downstreamKey ?? "") === (b.downstreamKey ?? ""), +) // ============================================================================ // DIRECT TESTCASE ENTITY SELECTORS diff --git a/web/packages/agenta-playground/src/state/execution/visibility.ts b/web/packages/agenta-playground/src/state/execution/visibility.ts new file mode 100644 index 0000000000..69c7e357dc --- /dev/null +++ b/web/packages/agenta-playground/src/state/execution/visibility.ts @@ -0,0 +1,78 @@ +/** + * Pure split logic for the playground inputs visibility rule. + * + * Given the template-referenced variable names and the testcase data, + * produces the three-way view the playground inputs body needs: + * + * - `inputs` : referenced variables, each carrying its testcase + * value (or `undefined` + `isDraft: true` when the + * template references it but the testcase has no + * column yet). + * - `unreferencedColumns`: testcase columns the prompt chain does NOT + * reference. Rendered under a collapsed footer in + * the UI so the row stays focused on what the + * prompt actually consumes. + * + * The atom layer (`selectors.ts`) wraps this with the live atom sources; + * keeping the pure helper here lets us unit-test the rule without a Jotai + * store. See approved design doc, Step 4 (variable visibility rule): + * ~/.gstack/projects/Agenta-AI-agenta/ardaerzin-playground-mustache-input-ux-design-*.md + * + * NOTE: `isSystemField` filtering is intentionally NOT done here — the atom + * layer trims system fields out of the testcase data dict before calling + * this helper, so the rule stays purely about referenced vs not. + */ + +export interface VariableEntry { + name: string + value: unknown + /** True when the prompt references the name but the testcase has no + * column for it yet — UI renders a draft card. */ + isDraft?: boolean +} + +export interface InputsVisibility { + /** Referenced variables, in the order the caller supplied them. Draft + * entries carry `value: undefined` and `isDraft: true`. */ + inputs: VariableEntry[] + /** Testcase columns NOT in the referenced set. Order preserved from + * `testcaseData` iteration. */ + unreferencedColumns: {name: string; value: unknown}[] +} + +export interface SplitInputsVisibilityArgs { + /** Names the prompt / evaluator chain references. Order is preserved in + * the `inputs` output. */ + referencedKeys: string[] + /** Current testcase row data. */ + testcaseData: Record +} + +/** + * Split the referenced + testcase universe into `inputs` (referenced, with + * draft annotation for missing) and `unreferencedColumns` (in testcase but + * not referenced). + * + * Pure — no atoms, no React, no jotai. Easy to unit-test. + */ +export function splitInputsVisibility({ + referencedKeys, + testcaseData, +}: SplitInputsVisibilityArgs): InputsVisibility { + const refsSet = new Set(referencedKeys) + + const inputs: VariableEntry[] = referencedKeys.map((name) => { + if (name in testcaseData) { + return {name, value: testcaseData[name]} + } + return {name, value: undefined, isDraft: true} + }) + + const unreferencedColumns: {name: string; value: unknown}[] = [] + for (const [name, value] of Object.entries(testcaseData)) { + if (refsSet.has(name)) continue + unreferencedColumns.push({name, value}) + } + + return {inputs, unreferencedColumns} +} From cace2ec6ff67a901c53a820680cc15b597ca5739 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:05:12 +0200 Subject: [PATCH 05/41] feat(frontend): add TemplateFormatPicker for the playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small antd Select for choosing how a prompt template renders (Mustache / Jinja2 / [Curly] / [F-string]). Wraps a vendored buildTemplateFormatOptions(currentFormat) helper that matches the WP-B3 web-handoff contract: - New / mustache / jinja2 prompts → ["mustache", "jinja2"] - Prompts on curly → ["mustache", "jinja2", "curly"] - Prompts on fstring → ["mustache", "jinja2", "fstring"] - Default for new prompts = "mustache" - Legacy formats never offered to other prompts - Unknown formats appended defensively, never coerced VENDORING NOTE: The buildTemplateFormatOptions helper is vendored from #4393 (still OPEN). When #4393 lands, the canonical version ships at agenta-entity-ui/src/DrillInView/SchemaControls/ templateFormatOptions.ts — this branch's copy gets deleted then and TemplateFormatPicker re-imports from there. See the file header for the full vendoring note. Differences vs #4393's version (labels, option shape, nullable input) are documented inline. TemplateFormatPicker itself is genuinely additive — #4393 only ships the options helper + wires it into PromptSchemaControl (drawer). The playground needs its own picker component. 15 new tests pin the options contract: new prompts, mustache/jinja2 stored, curly/fstring legacy appending, hint tags, unknown defensive fallback, never-coerce idempotency. --- .../unit/template-format-options.test.ts | 115 ++++++++++++++++++ .../template-format/TemplateFormatPicker.tsx | 99 +++++++++++++++ .../src/template-format/index.ts | 23 ++++ .../template-format/templateFormatOptions.ts | 107 ++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 web/packages/agenta-entities/tests/unit/template-format-options.test.ts create mode 100644 web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx create mode 100644 web/packages/agenta-entity-ui/src/template-format/index.ts create mode 100644 web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts diff --git a/web/packages/agenta-entities/tests/unit/template-format-options.test.ts b/web/packages/agenta-entities/tests/unit/template-format-options.test.ts new file mode 100644 index 0000000000..0ab923972d --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/template-format-options.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for the template-format picker options helper. + * + * Pin: the design doc + WP-B3 web-handoff guarantee: + * - New / mustache / jinja2 → ["mustache", "jinja2"] + * - Curly stored → ["mustache", "jinja2", "curly"] (legacy appended) + * - F-string stored → ["mustache", "jinja2", "fstring"] + * - Mustache is the default (first option, hint "default") + * - Legacy formats never offered to new prompts + * - Unknown / future formats: appended, never coerced + * + * Stopgap location: agenta-entity-ui doesn't have its own vitest runner yet. + * Cross-package relative import below is a test-time dep only. + */ +import {describe, expect, it} from "vitest" + +import { + buildTemplateFormatOptions, + DEFAULT_TEMPLATE_FORMAT, +} from "../../../agenta-entity-ui/src/template-format/templateFormatOptions" + +describe("buildTemplateFormatOptions", () => { + describe("new prompt (no currentFormat)", () => { + it("offers exactly mustache + jinja2 when value is null", () => { + const opts = buildTemplateFormatOptions(null) + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) + }) + + it("offers exactly mustache + jinja2 when value is undefined", () => { + const opts = buildTemplateFormatOptions(undefined) + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) + }) + + it("offers exactly mustache + jinja2 when value is empty string", () => { + const opts = buildTemplateFormatOptions("") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) + }) + + it("marks mustache as the default", () => { + const opts = buildTemplateFormatOptions(null) + expect(opts[0]).toEqual({value: "mustache", label: "Mustache", hint: "default"}) + }) + + it("DEFAULT_TEMPLATE_FORMAT is mustache (matches the first offered option)", () => { + expect(DEFAULT_TEMPLATE_FORMAT).toBe("mustache") + }) + }) + + describe("prompt already on an offered format", () => { + it("returns the same offered set when current is mustache", () => { + const opts = buildTemplateFormatOptions("mustache") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) + }) + + it("returns the same offered set when current is jinja2", () => { + const opts = buildTemplateFormatOptions("jinja2") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) + }) + }) + + describe("prompt on a legacy format", () => { + it("appends curly to the offered set when current is curly", () => { + const opts = buildTemplateFormatOptions("curly") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "curly"]) + }) + + it("appends fstring when current is fstring", () => { + const opts = buildTemplateFormatOptions("fstring") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "fstring"]) + }) + + it("tags appended legacy option with hint 'legacy'", () => { + const opts = buildTemplateFormatOptions("curly") + const curly = opts.find((o) => o.value === "curly") + expect(curly?.hint).toBe("legacy") + }) + + it("does NOT mark mustache as legacy when a legacy is current", () => { + const opts = buildTemplateFormatOptions("curly") + const mustache = opts.find((o) => o.value === "mustache") + expect(mustache?.hint).toBe("default") + }) + }) + + describe("unknown formats (defensive — future values, stale data)", () => { + it("appends the unknown value so the user keeps the ability to see + change it", () => { + const opts = buildTemplateFormatOptions("future_format") + expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "future_format"]) + }) + + it("does not tag unknown values as 'legacy'", () => { + const opts = buildTemplateFormatOptions("future_format") + const future = opts.find((o) => o.value === "future_format") + expect(future?.hint).toBeUndefined() + }) + }) + + describe("never coerce, never silently drop", () => { + it("preserves the current value across calls (idempotent)", () => { + const fromCurly = buildTemplateFormatOptions("curly") + const fromCurlyAgain = buildTemplateFormatOptions("curly") + expect(fromCurly).toEqual(fromCurlyAgain) + }) + + it("returned options always include a sensible label", () => { + for (const current of [null, undefined, "mustache", "jinja2", "curly", "fstring"]) { + const opts = buildTemplateFormatOptions(current as string | null | undefined) + for (const opt of opts) { + expect(typeof opt.label).toBe("string") + expect(opt.label.length).toBeGreaterThan(0) + } + } + }) + }) +}) diff --git a/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx b/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx new file mode 100644 index 0000000000..15ac2ff8f2 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx @@ -0,0 +1,99 @@ +/** + * TemplateFormatPicker — small dropdown for choosing how a prompt template + * renders (Mustache / Jinja2 / [Curly] / [F-string]). + * + * Used by the playground prompt-config surface (Step 5/6 of the playground + * mustache + input UX branch). The picker is *presentational*: it doesn't + * know about variant entities or molecules. The wiring layer (Step 6, in + * OSS) wires it up to the active prompt's `template_format` field. + * + * Value handling: + * - `value` is a free string so prompts storing legacy formats (`curly`, + * `fstring`) keep their selection visible; never silently coerced. + * - Options are computed by `buildTemplateFormatOptions(value)` — the + * vendored helper alongside this file. See `templateFormatOptions.ts` + * for the contract and the vendoring note. + * + * Visual style: + * - Compact antd `Select`, sized to fit alongside a label. + * - "default" / "legacy" hints render as a small right-aligned chip. + */ + +import {useMemo} from "react" + +import {Select, Tag} from "antd" + +import { + buildTemplateFormatOptions, + DEFAULT_TEMPLATE_FORMAT, + type TemplateFormatOption, +} from "./templateFormatOptions" + +export interface TemplateFormatPickerProps { + /** Current template_format from the prompt config. `null` / `undefined` + * → falls back to `DEFAULT_TEMPLATE_FORMAT` (mustache). */ + value?: string | null + onChange: (next: string) => void + disabled?: boolean + /** Optional className for layout overrides. */ + className?: string +} + +export function TemplateFormatPicker({ + value, + onChange, + disabled, + className, +}: TemplateFormatPickerProps) { + const options = useMemo(() => buildTemplateFormatOptions(value), [value]) + const resolvedValue = value ?? DEFAULT_TEMPLATE_FORMAT + + return ( + + size="small" + value={resolvedValue} + disabled={disabled} + onChange={onChange} + className={className} + style={{minWidth: 120}} + popupMatchSelectWidth={false} + optionLabelProp="label" + options={options.map((opt) => ({ + value: opt.value, + label: opt.label, + }))} + optionRender={(option) => { + const opt = options.find((o) => o.value === option.value) as + | TemplateFormatOption + | undefined + return ( +
+ {opt?.label ?? String(option.label)} + {opt?.hint ? ( + + {opt.hint} + + ) : null} +
+ ) + }} + /> + ) +} + +export default TemplateFormatPicker diff --git a/web/packages/agenta-entity-ui/src/template-format/index.ts b/web/packages/agenta-entity-ui/src/template-format/index.ts new file mode 100644 index 0000000000..3969e8c04a --- /dev/null +++ b/web/packages/agenta-entity-ui/src/template-format/index.ts @@ -0,0 +1,23 @@ +/** + * `@agenta/entity-ui/template-format` — picker + options helper for the + * prompt template_format choice (Mustache / Jinja2 / [Curly] / [F-string]). + * + * import {TemplateFormatPicker, buildTemplateFormatOptions} from "@agenta/entity-ui/template-format" + * + * Vendored from #4393 (WP-B3 frontend slice) for this branch. See + * `templateFormatOptions.ts` for the vendoring note: when #4393 merges, + * this whole subpath gets diffed against its version and one of them gets + * deleted. + */ + +export {TemplateFormatPicker, type TemplateFormatPickerProps} from "./TemplateFormatPicker" + +export { + buildTemplateFormatOptions, + DEFAULT_TEMPLATE_FORMAT, + OFFERED_TEMPLATE_FORMATS, + LEGACY_TEMPLATE_FORMATS, + ALL_TEMPLATE_FORMATS, + type TemplateFormatOption, + type TemplateFormatValue, +} from "./templateFormatOptions" diff --git a/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts b/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts new file mode 100644 index 0000000000..d85d97b9f9 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts @@ -0,0 +1,107 @@ +/** + * Template-format picker options for the playground (and any other surface + * that lets a user choose how a prompt template renders). + * + * The contract mirrors what WP-B3 (#4393) ships under + * `agenta-entity-ui/src/DrillInView/SchemaControls/templateFormatOptions.ts`. + * Vendored here so the playground picker (Step 5 of the playground mustache + * branch) can ship before #4393 merges. + * + * VENDORING NOTE — when #4393 lands: + * - Diff this file against #4393's version. + * - Adopt whichever is canonical (likely #4393's, since it'll have shipped + * the matching `template_format` type widening across the editor / + * chat-message / schema-control surfaces). + * - Update consumers (TemplateFormatPicker, downstream importers) to point + * at the canonical helper. + * - Delete this file. + * + * Behavior (matches the design doc + #4393 web-handoff): + * - New / mustache / jinja2 prompts → options = ["mustache", "jinja2"] + * - Prompt already storing `curly` → ["mustache", "jinja2", "curly"] + * - Prompt already storing `fstring` → ["mustache", "jinja2", "fstring"] + * - Never coerce: the stored format stays selectable for the prompt that + * already uses it; legacy formats are not offered to other prompts. + */ + +/** Formats actively offered to NEW prompts. */ +export const OFFERED_TEMPLATE_FORMATS = ["mustache", "jinja2"] as const + +/** Formats kept around for backwards compatibility (hidden unless already + * selected on a particular prompt). */ +export const LEGACY_TEMPLATE_FORMATS = ["curly", "fstring"] as const + +/** Every recognized format (offered + legacy). */ +export const ALL_TEMPLATE_FORMATS = [ + ...OFFERED_TEMPLATE_FORMATS, + ...LEGACY_TEMPLATE_FORMATS, +] as const + +export type TemplateFormatValue = (typeof ALL_TEMPLATE_FORMATS)[number] + +export interface TemplateFormatOption { + value: string + label: string + /** Tiny right-aligned hint (e.g. "default", "legacy"). */ + hint?: string +} + +const FORMAT_LABEL: Record = { + mustache: "Mustache", + jinja2: "Jinja2", + curly: "Curly", + fstring: "F-string", +} + +const HINT_BY_OFFERED: Record = { + mustache: "default", +} + +/** + * Compute the dropdown options for the template-format picker. + * + * @param currentFormat The format currently stored on the prompt (or null + * / undefined for a new prompt). When the current value is a legacy + * format, it is APPENDED to the offered set so the user keeps the + * ability to see and change it. Other prompts will never be offered + * the legacy formats. + */ +export function buildTemplateFormatOptions( + currentFormat: string | null | undefined, +): TemplateFormatOption[] { + const offered: TemplateFormatOption[] = OFFERED_TEMPLATE_FORMATS.map((value) => ({ + value, + label: FORMAT_LABEL[value] ?? value, + hint: HINT_BY_OFFERED[value], + })) + + if (!currentFormat) return offered + + const isOffered = (OFFERED_TEMPLATE_FORMATS as readonly string[]).includes(currentFormat) + if (isOffered) return offered + + const isLegacy = (LEGACY_TEMPLATE_FORMATS as readonly string[]).includes(currentFormat) + if (isLegacy) { + return [ + ...offered, + { + value: currentFormat, + label: FORMAT_LABEL[currentFormat] ?? currentFormat, + hint: "legacy", + }, + ] + } + + // Unknown format (defensive): keep it visible so the user can see and + // change it. Don't coerce silently. + return [ + ...offered, + { + value: currentFormat, + label: currentFormat, + }, + ] +} + +/** Default format for a new prompt (no `currentFormat` stored). */ +export const DEFAULT_TEMPLATE_FORMAT = "mustache" From 42047fd23a32e306c2f6d8a30ac50ac71c21779e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:06:13 +0200 Subject: [PATCH 06/41] fix(frontend): wire @agenta/entity-ui/template-format subpath export Missed the package.json hunk in the TemplateFormatPicker commit (cace2ec6f). Adds the "./template-format" subpath so consumers can import {TemplateFormatPicker} from "@agenta/entity-ui/template-format". --- web/packages/agenta-entity-ui/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json index 69141dc923..18f6fdd043 100644 --- a/web/packages/agenta-entity-ui/package.json +++ b/web/packages/agenta-entity-ui/package.json @@ -17,6 +17,7 @@ "./gatewayTool": "./src/gatewayTool/index.ts", "./modals": "./src/modals/index.ts", "./selection": "./src/selection/index.ts", + "./template-format": "./src/template-format/index.ts", "./testcase": "./src/testcase/index.ts", "./variant": "./src/variant/index.ts", "./view-types": "./src/view-types/index.ts", From b0a1dda66921559d4a8e1bd55a0f06abcae9ccda Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:08:00 +0200 Subject: [PATCH 07/41] feat(frontend): wire PlaygroundInputsBody into SingleLayout (feature-flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in path through SingleLayout's flat (non-grouped) branch that renders PlaygroundInputsBodyHost in place of the per-variable VariableControlAdapter loop. Off by default — existing UX is preserved on merge; OSS (or a dev toggle) flips useNewPlaygroundInputsBodyAtom to true to surface the new V2-aligned cards. New files: - agenta-playground-ui/src/state/featureFlags.ts — defines useNewPlaygroundInputsBodyAtom (boolean, default false). Plain session atom; promote to atomWithStorage if it becomes a user pref. - agenta-playground-ui/.../PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx atom-aware wrapper that reads inputsVisibility({testcaseId, downstreamKey}) and writes via setTestcaseCellValue. Drafts route through the same setter (it creates the column on first set). Modified: - agenta-playground-ui/src/state/index.ts — exports the flag atom. - PlaygroundInputsBody/index.tsx — re-exports the Host. - SingleLayout.tsx — non-grouped branch checks the flag and renders Host instead of variableIds.map(renderVariable) when enabled. Deferred (explicit follow-ups documented in approved design doc): - ComparisonLayout — second adapter consumer, same swap pattern. - Grouped evaluator layout — keeps VariableControlAdapter per design doc ("the adapter stays for evaluator-playground / chain-step"). - TemplateFormatPicker placement into an OSS prompt-config surface — needs design-team sign-off on placement per design doc Open Q2. - Default-flip the feature flag — small follow-up commit after the user verifies the new UX in dev. --- .../assets/ExecutionRow/SingleLayout.tsx | 32 +++++++- .../PlaygroundInputsBodyHost.tsx | 75 +++++++++++++++++++ .../components/PlaygroundInputsBody/index.tsx | 3 + .../src/state/featureFlags.ts | 30 ++++++++ .../agenta-playground-ui/src/state/index.ts | 2 + 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx create mode 100644 web/packages/agenta-playground-ui/src/state/featureFlags.ts diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx index 7df0f3d29a..9ae6153e53 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx @@ -26,9 +26,6 @@ import clsx from "clsx" import {useAtom, useAtomValue, useSetAtom} from "jotai" import {atomWithStorage} from "jotai/utils" -import {VariableControlAdapter} from "@agenta/playground-ui/adapters" -import {openPlaygroundFocusDrawerAtom} from "@agenta/playground-ui/state" - import {usePlaygroundUIOptional} from "../../../../context/PlaygroundUIContext" import {useRepetitionResult} from "../../../../hooks/useRepetitionResult" import {getShortTestcaseId} from "../../../../utils/testcaseLabel" @@ -50,6 +47,13 @@ import { import {ExecutionRowRunControl, usePlaygroundNodeLabels} from "./shared" +import {VariableControlAdapter} from "@agenta/playground-ui/adapters" +import {PlaygroundInputsBodyHost} from "@agenta/playground-ui/playground-inputs-body" +import { + openPlaygroundFocusDrawerAtom, + useNewPlaygroundInputsBodyAtom, +} from "@agenta/playground-ui/state" + // Dismissable callout that explains what the two evaluator playground // variables represent (the application being evaluated's inputs and output). // Persisted per-user via localStorage so it doesn't reappear after the user @@ -673,6 +677,11 @@ const SingleView = ({ const isExecutionExpanded = inputOnly || !isCollapsed const isWaitingForVariableControls = variableIds.length === 0 && (schemaInputKeys.length > 0 || Boolean(runnableQuery.isPending)) + + // Feature flag — when true, the non-grouped (flat) variable list renders + // through `PlaygroundInputsBodyHost` (V2-aligned bordered cards with + // type chips + "View as ▾" dropdown). Off by default; OSS opts in. + const useNewInputsBody = useAtomValue(useNewPlaygroundInputsBodyAtom) const collapseDurationMs = hasInteractedWithCollapse ? 300 : 0 useEffect(() => { @@ -868,6 +877,23 @@ const SingleView = ({ // Flat layout — apps, and evaluators with no // extracted field ports (default template). if (!useGroupedLayout) { + // New playground inputs body — V2-aligned + // bordered cards with type chips + "View + // as ▾" dropdowns. Behind a feature flag + // so the existing per-variable layout + // stays default until the new UX is + // signed off. See approved design doc, + // Step 6 deferred follow-ups for + // ComparisonLayout + picker placement. + if (useNewInputsBody) { + return ( + + ) + } return variableIds.map((id) => renderVariable(id)) } diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx new file mode 100644 index 0000000000..22c2bab4ca --- /dev/null +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx @@ -0,0 +1,75 @@ +/** + * PlaygroundInputsBodyHost — atom-aware wrapper around `PlaygroundInputsBody`. + * + * Bridges the playground execution state to the presentational component: + * - `inputs` + `unreferencedColumns` come from + * `executionItemController.selectors.inputsVisibility({testcaseId, downstreamKey})`. + * - Edits flow to `executionItemController.actions.setTestcaseCellValue`, + * which writes through `testcaseMolecule.actions.update` so the testcase + * entity is the single source of truth. + * - Draft variables (referenced by prompt but absent from testcase) write + * through the SAME action — `setTestcaseCellValue` is happy to create a + * new column on first set, so we don't need a separate `onAddDraftColumn`. + * + * This is the integration point used by `SingleLayout` when the + * `useNewPlaygroundInputsBodyAtom` feature flag is on. ComparisonLayout will + * follow in a future commit. + */ + +import {useCallback, useMemo} from "react" + +import {executionItemController} from "@agenta/playground" +import {useAtomValue, useSetAtom} from "jotai" + +import {PlaygroundInputsBody} from "./PlaygroundInputsBody" + +export interface PlaygroundInputsBodyHostProps { + /** Testcase row ID (also the playground generation row ID — they're + * the same in the loadable routing per the testcaseMolecule contract). */ + rowId: string + /** Downstream key used by the visibility selector to namespace its + * computation per-evaluator-chain. Should match the key the caller + * uses with `variableKeysForDownstream`. */ + downstreamKey: string + /** Whether the cards are editable (vs read-only). */ + editable: boolean +} + +export function PlaygroundInputsBodyHost({ + rowId, + downstreamKey, + editable, +}: PlaygroundInputsBodyHostProps) { + const visibility = useAtomValue( + useMemo( + () => + executionItemController.selectors.inputsVisibility({ + testcaseId: rowId, + downstreamKey, + }), + [rowId, downstreamKey], + ), + ) + + const setCellValue = useSetAtom(executionItemController.actions.setTestcaseCellValue) + + const handleValueChange = useCallback( + (name: string, value: unknown) => { + setCellValue({testcaseId: rowId, column: name, value}) + }, + [setCellValue, rowId], + ) + + return ( + + ) +} diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx index 833d41ae28..ca2d52273c 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx @@ -12,6 +12,9 @@ export {PlaygroundInputsBody} from "./PlaygroundInputsBody" export type {PlaygroundInputsBodyProps, PlaygroundInputsBodyVariable} from "./PlaygroundInputsBody" +export {PlaygroundInputsBodyHost} from "./PlaygroundInputsBodyHost" +export type {PlaygroundInputsBodyHostProps} from "./PlaygroundInputsBodyHost" + export {VariableCard} from "./VariableCard" export {UnreferencedColumnsFooter} from "./UnreferencedColumnsFooter" diff --git a/web/packages/agenta-playground-ui/src/state/featureFlags.ts b/web/packages/agenta-playground-ui/src/state/featureFlags.ts new file mode 100644 index 0000000000..8d5db2661a --- /dev/null +++ b/web/packages/agenta-playground-ui/src/state/featureFlags.ts @@ -0,0 +1,30 @@ +/** + * Playground UI feature flags. + * + * Small Jotai atoms for opt-in / behind-the-scenes UI swaps that we want + * to ship dark-launched. OSS (or any consumer) flips them on once a + * change is ready to be user-visible. + * + * Today these are session-scoped plain atoms — no persistence, no env-var + * wiring. Promote to `atomWithStorage` keyed by user/app if any of them + * stick around long-term as user preferences. + */ + +import {atom} from "jotai" + +/** + * When `true`, the playground's per-variable input cells (rendered today + * by `VariableControlAdapter`) get replaced with the V2-aligned + * `PlaygroundInputsBody` component (bordered card per variable, type + * chip + "View as ▾" dropdown, native JSON edits). + * + * Default `false` — existing rendering is preserved. The consumer (OSS + * playground entry, an `init()` effect, a dev toggle) flips this on once + * the new UX is signed off. When the new path is the default for everyone, + * delete this flag and the conditional in `SingleLayout`. + * + * Wired only for SingleLayout's flat (non-grouped) path in the initial + * cut. Grouped evaluator layouts and ComparisonLayout still use the + * adapter — see the deferred follow-ups in the approved design doc. + */ +export const useNewPlaygroundInputsBodyAtom = atom(false) diff --git a/web/packages/agenta-playground-ui/src/state/index.ts b/web/packages/agenta-playground-ui/src/state/index.ts index 9f160be258..7453ac61cf 100644 --- a/web/packages/agenta-playground-ui/src/state/index.ts +++ b/web/packages/agenta-playground-ui/src/state/index.ts @@ -6,3 +6,5 @@ export { resetPlaygroundFocusDrawerAtom, type PlaygroundFocusDrawerState, } from "./focusDrawer" + +export {useNewPlaygroundInputsBodyAtom} from "./featureFlags" From ba5fa4c7ba0a11d2e991f5ed1589d5b9356ab38f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:14:38 +0200 Subject: [PATCH 08/41] chore(frontend): reconcile post-merge with #4393's canonical helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that WP-B3 (#4393) is merged in, drop the vendored duplicates and align on the canonical exports: - Delete agenta-entity-ui/src/template-format/templateFormatOptions.ts (vendored copy). Re-point TemplateFormatPicker to import buildTemplateFormatOptions + TemplateFormat from #4393's canonical location: src/DrillInView/SchemaControls/templateFormatOptions.ts. - Adopt #4393's labels ("Prompt Syntax: Mustache" / "Jinja2" / "Curly" / "F-string") via TEMPLATE_FORMAT_LABELS — drawer + playground now share the same vocabulary. - Drop my hint chip system; #4393's option shape is {label, value}. - Drop my vendored template-format-options.test.ts — superseded by #4393's agenta-entity-ui/tests/unit/templateFormatOptions.test.ts. - Migrate the other two stopgap tests (view-types + formatters) from agenta-entities/tests/unit/ to agenta-entity-ui/tests/unit/ now that #4393 ships a vitest runner in entity-ui (vitest.config.ts). Relative imports tightened to ../../src/view-types/. Remaining stopgap in agenta-entities/tests/unit/: - playground-inputs-visibility.test.ts — tests @agenta/playground code, which still has no vitest runner. Keep there. - build-evaluator-execution-inputs.test.ts — tests @agenta/entities code, naturally lives in entities. Stays. Test totals after reconciliation: - @agenta/entity-ui — 58 tests (4 files: view-types, formatters, my + #4393's chatPromptsMustache, #4393's templateFormatOptions) - @agenta/entities — 353 tests --- .../unit/template-format-options.test.ts | 115 ------------------ .../template-format/TemplateFormatPicker.tsx | 89 +++++--------- .../src/template-format/index.ts | 24 ++-- .../template-format/templateFormatOptions.ts | 107 ---------------- .../unit/playground-inputs-formatters.test.ts | 10 +- .../tests/unit/view-types.test.ts | 18 +-- 6 files changed, 48 insertions(+), 315 deletions(-) delete mode 100644 web/packages/agenta-entities/tests/unit/template-format-options.test.ts delete mode 100644 web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts rename web/packages/{agenta-entities => agenta-entity-ui}/tests/unit/playground-inputs-formatters.test.ts (93%) rename web/packages/{agenta-entities => agenta-entity-ui}/tests/unit/view-types.test.ts (89%) diff --git a/web/packages/agenta-entities/tests/unit/template-format-options.test.ts b/web/packages/agenta-entities/tests/unit/template-format-options.test.ts deleted file mode 100644 index 0ab923972d..0000000000 --- a/web/packages/agenta-entities/tests/unit/template-format-options.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Unit tests for the template-format picker options helper. - * - * Pin: the design doc + WP-B3 web-handoff guarantee: - * - New / mustache / jinja2 → ["mustache", "jinja2"] - * - Curly stored → ["mustache", "jinja2", "curly"] (legacy appended) - * - F-string stored → ["mustache", "jinja2", "fstring"] - * - Mustache is the default (first option, hint "default") - * - Legacy formats never offered to new prompts - * - Unknown / future formats: appended, never coerced - * - * Stopgap location: agenta-entity-ui doesn't have its own vitest runner yet. - * Cross-package relative import below is a test-time dep only. - */ -import {describe, expect, it} from "vitest" - -import { - buildTemplateFormatOptions, - DEFAULT_TEMPLATE_FORMAT, -} from "../../../agenta-entity-ui/src/template-format/templateFormatOptions" - -describe("buildTemplateFormatOptions", () => { - describe("new prompt (no currentFormat)", () => { - it("offers exactly mustache + jinja2 when value is null", () => { - const opts = buildTemplateFormatOptions(null) - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) - }) - - it("offers exactly mustache + jinja2 when value is undefined", () => { - const opts = buildTemplateFormatOptions(undefined) - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) - }) - - it("offers exactly mustache + jinja2 when value is empty string", () => { - const opts = buildTemplateFormatOptions("") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) - }) - - it("marks mustache as the default", () => { - const opts = buildTemplateFormatOptions(null) - expect(opts[0]).toEqual({value: "mustache", label: "Mustache", hint: "default"}) - }) - - it("DEFAULT_TEMPLATE_FORMAT is mustache (matches the first offered option)", () => { - expect(DEFAULT_TEMPLATE_FORMAT).toBe("mustache") - }) - }) - - describe("prompt already on an offered format", () => { - it("returns the same offered set when current is mustache", () => { - const opts = buildTemplateFormatOptions("mustache") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) - }) - - it("returns the same offered set when current is jinja2", () => { - const opts = buildTemplateFormatOptions("jinja2") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2"]) - }) - }) - - describe("prompt on a legacy format", () => { - it("appends curly to the offered set when current is curly", () => { - const opts = buildTemplateFormatOptions("curly") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "curly"]) - }) - - it("appends fstring when current is fstring", () => { - const opts = buildTemplateFormatOptions("fstring") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "fstring"]) - }) - - it("tags appended legacy option with hint 'legacy'", () => { - const opts = buildTemplateFormatOptions("curly") - const curly = opts.find((o) => o.value === "curly") - expect(curly?.hint).toBe("legacy") - }) - - it("does NOT mark mustache as legacy when a legacy is current", () => { - const opts = buildTemplateFormatOptions("curly") - const mustache = opts.find((o) => o.value === "mustache") - expect(mustache?.hint).toBe("default") - }) - }) - - describe("unknown formats (defensive — future values, stale data)", () => { - it("appends the unknown value so the user keeps the ability to see + change it", () => { - const opts = buildTemplateFormatOptions("future_format") - expect(opts.map((o) => o.value)).toEqual(["mustache", "jinja2", "future_format"]) - }) - - it("does not tag unknown values as 'legacy'", () => { - const opts = buildTemplateFormatOptions("future_format") - const future = opts.find((o) => o.value === "future_format") - expect(future?.hint).toBeUndefined() - }) - }) - - describe("never coerce, never silently drop", () => { - it("preserves the current value across calls (idempotent)", () => { - const fromCurly = buildTemplateFormatOptions("curly") - const fromCurlyAgain = buildTemplateFormatOptions("curly") - expect(fromCurly).toEqual(fromCurlyAgain) - }) - - it("returned options always include a sensible label", () => { - for (const current of [null, undefined, "mustache", "jinja2", "curly", "fstring"]) { - const opts = buildTemplateFormatOptions(current as string | null | undefined) - for (const opt of opts) { - expect(typeof opt.label).toBe("string") - expect(opt.label.length).toBeGreaterThan(0) - } - } - }) - }) -}) diff --git a/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx b/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx index 15ac2ff8f2..bc7346a891 100644 --- a/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx +++ b/web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx @@ -2,38 +2,41 @@ * TemplateFormatPicker — small dropdown for choosing how a prompt template * renders (Mustache / Jinja2 / [Curly] / [F-string]). * - * Used by the playground prompt-config surface (Step 5/6 of the playground - * mustache + input UX branch). The picker is *presentational*: it doesn't - * know about variant entities or molecules. The wiring layer (Step 6, in - * OSS) wires it up to the active prompt's `template_format` field. + * Used by the playground prompt-config surface to let the user switch + * `template_format`. Presentational only — the wiring layer feeds in the + * current value and the change handler. * - * Value handling: - * - `value` is a free string so prompts storing legacy formats (`curly`, - * `fstring`) keep their selection visible; never silently coerced. - * - Options are computed by `buildTemplateFormatOptions(value)` — the - * vendored helper alongside this file. See `templateFormatOptions.ts` - * for the contract and the vendoring note. + * Options + labels come from `buildTemplateFormatOptions` shipped by + * WP-B3 (#4393) in `agenta-entity-ui/src/DrillInView/SchemaControls/`. + * Contract: + * - New / mustache / jinja2 prompts → ["mustache", "jinja2"] + * - Prompts on curly → ["mustache", "jinja2", "curly"] + * - Prompts on fstring → ["mustache", "jinja2", "fstring"] + * - Labels: "Prompt Syntax: Mustache" / "Jinja2" / "Curly" / "F-string" + * - Never coerce: legacy formats stay selectable on prompts that already + * use them; never offered to other prompts. * - * Visual style: - * - Compact antd `Select`, sized to fit alongside a label. - * - "default" / "legacy" hints render as a small right-aligned chip. + * The drawer's `PromptSchemaControl` already consumes the same helper for + * its inline picker — drawer and playground now share both options and + * labels, so users get a consistent vocabulary across surfaces. */ import {useMemo} from "react" -import {Select, Tag} from "antd" +import {Select} from "antd" import { buildTemplateFormatOptions, - DEFAULT_TEMPLATE_FORMAT, - type TemplateFormatOption, -} from "./templateFormatOptions" + type TemplateFormat, +} from "../DrillInView/SchemaControls/templateFormatOptions" + +const DEFAULT_TEMPLATE_FORMAT: TemplateFormat = "mustache" export interface TemplateFormatPickerProps { /** Current template_format from the prompt config. `null` / `undefined` - * → falls back to `DEFAULT_TEMPLATE_FORMAT` (mustache). */ - value?: string | null - onChange: (next: string) => void + * → falls back to mustache (the WP-B3 default for new prompts). */ + value?: TemplateFormat | string | null + onChange: (next: TemplateFormat) => void disabled?: boolean /** Optional className for layout overrides. */ className?: string @@ -45,53 +48,19 @@ export function TemplateFormatPicker({ disabled, className, }: TemplateFormatPickerProps) { - const options = useMemo(() => buildTemplateFormatOptions(value), [value]) - const resolvedValue = value ?? DEFAULT_TEMPLATE_FORMAT + const resolvedValue = (value as TemplateFormat | null | undefined) ?? DEFAULT_TEMPLATE_FORMAT + const options = useMemo(() => buildTemplateFormatOptions(resolvedValue), [resolvedValue]) return ( - + size="small" - value={resolvedValue} + value={resolvedValue as TemplateFormat} disabled={disabled} onChange={onChange} className={className} - style={{minWidth: 120}} + style={{minWidth: 180}} popupMatchSelectWidth={false} - optionLabelProp="label" - options={options.map((opt) => ({ - value: opt.value, - label: opt.label, - }))} - optionRender={(option) => { - const opt = options.find((o) => o.value === option.value) as - | TemplateFormatOption - | undefined - return ( -
- {opt?.label ?? String(option.label)} - {opt?.hint ? ( - - {opt.hint} - - ) : null} -
- ) - }} + options={options} /> ) } diff --git a/web/packages/agenta-entity-ui/src/template-format/index.ts b/web/packages/agenta-entity-ui/src/template-format/index.ts index 3969e8c04a..18d755d567 100644 --- a/web/packages/agenta-entity-ui/src/template-format/index.ts +++ b/web/packages/agenta-entity-ui/src/template-format/index.ts @@ -1,23 +1,21 @@ /** - * `@agenta/entity-ui/template-format` — picker + options helper for the - * prompt template_format choice (Mustache / Jinja2 / [Curly] / [F-string]). + * `@agenta/entity-ui/template-format` — picker component for the prompt + * template_format choice (Mustache / Jinja2 / [Curly] / [F-string]). * - * import {TemplateFormatPicker, buildTemplateFormatOptions} from "@agenta/entity-ui/template-format" + * import {TemplateFormatPicker} from "@agenta/entity-ui/template-format" * - * Vendored from #4393 (WP-B3 frontend slice) for this branch. See - * `templateFormatOptions.ts` for the vendoring note: when #4393 merges, - * this whole subpath gets diffed against its version and one of them gets - * deleted. + * The options + labels live in WP-B3's + * `agenta-entity-ui/src/DrillInView/SchemaControls/templateFormatOptions.ts` + * (the same helper the drawer's PromptSchemaControl consumes). This subpath + * exists as a clean home for the picker component used outside the drawer + * (e.g. the playground); re-exports the helper for convenience. */ export {TemplateFormatPicker, type TemplateFormatPickerProps} from "./TemplateFormatPicker" export { buildTemplateFormatOptions, - DEFAULT_TEMPLATE_FORMAT, OFFERED_TEMPLATE_FORMATS, - LEGACY_TEMPLATE_FORMATS, - ALL_TEMPLATE_FORMATS, - type TemplateFormatOption, - type TemplateFormatValue, -} from "./templateFormatOptions" + TEMPLATE_FORMAT_LABELS, + type TemplateFormat, +} from "../DrillInView/SchemaControls/templateFormatOptions" diff --git a/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts b/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts deleted file mode 100644 index d85d97b9f9..0000000000 --- a/web/packages/agenta-entity-ui/src/template-format/templateFormatOptions.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Template-format picker options for the playground (and any other surface - * that lets a user choose how a prompt template renders). - * - * The contract mirrors what WP-B3 (#4393) ships under - * `agenta-entity-ui/src/DrillInView/SchemaControls/templateFormatOptions.ts`. - * Vendored here so the playground picker (Step 5 of the playground mustache - * branch) can ship before #4393 merges. - * - * VENDORING NOTE — when #4393 lands: - * - Diff this file against #4393's version. - * - Adopt whichever is canonical (likely #4393's, since it'll have shipped - * the matching `template_format` type widening across the editor / - * chat-message / schema-control surfaces). - * - Update consumers (TemplateFormatPicker, downstream importers) to point - * at the canonical helper. - * - Delete this file. - * - * Behavior (matches the design doc + #4393 web-handoff): - * - New / mustache / jinja2 prompts → options = ["mustache", "jinja2"] - * - Prompt already storing `curly` → ["mustache", "jinja2", "curly"] - * - Prompt already storing `fstring` → ["mustache", "jinja2", "fstring"] - * - Never coerce: the stored format stays selectable for the prompt that - * already uses it; legacy formats are not offered to other prompts. - */ - -/** Formats actively offered to NEW prompts. */ -export const OFFERED_TEMPLATE_FORMATS = ["mustache", "jinja2"] as const - -/** Formats kept around for backwards compatibility (hidden unless already - * selected on a particular prompt). */ -export const LEGACY_TEMPLATE_FORMATS = ["curly", "fstring"] as const - -/** Every recognized format (offered + legacy). */ -export const ALL_TEMPLATE_FORMATS = [ - ...OFFERED_TEMPLATE_FORMATS, - ...LEGACY_TEMPLATE_FORMATS, -] as const - -export type TemplateFormatValue = (typeof ALL_TEMPLATE_FORMATS)[number] - -export interface TemplateFormatOption { - value: string - label: string - /** Tiny right-aligned hint (e.g. "default", "legacy"). */ - hint?: string -} - -const FORMAT_LABEL: Record = { - mustache: "Mustache", - jinja2: "Jinja2", - curly: "Curly", - fstring: "F-string", -} - -const HINT_BY_OFFERED: Record = { - mustache: "default", -} - -/** - * Compute the dropdown options for the template-format picker. - * - * @param currentFormat The format currently stored on the prompt (or null - * / undefined for a new prompt). When the current value is a legacy - * format, it is APPENDED to the offered set so the user keeps the - * ability to see and change it. Other prompts will never be offered - * the legacy formats. - */ -export function buildTemplateFormatOptions( - currentFormat: string | null | undefined, -): TemplateFormatOption[] { - const offered: TemplateFormatOption[] = OFFERED_TEMPLATE_FORMATS.map((value) => ({ - value, - label: FORMAT_LABEL[value] ?? value, - hint: HINT_BY_OFFERED[value], - })) - - if (!currentFormat) return offered - - const isOffered = (OFFERED_TEMPLATE_FORMATS as readonly string[]).includes(currentFormat) - if (isOffered) return offered - - const isLegacy = (LEGACY_TEMPLATE_FORMATS as readonly string[]).includes(currentFormat) - if (isLegacy) { - return [ - ...offered, - { - value: currentFormat, - label: FORMAT_LABEL[currentFormat] ?? currentFormat, - hint: "legacy", - }, - ] - } - - // Unknown format (defensive): keep it visible so the user can see and - // change it. Don't coerce silently. - return [ - ...offered, - { - value: currentFormat, - label: currentFormat, - }, - ] -} - -/** Default format for a new prompt (no `currentFormat` stored). */ -export const DEFAULT_TEMPLATE_FORMAT = "mustache" diff --git a/web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts similarity index 93% rename from web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts rename to web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts index d4374fce06..b6d07f948b 100644 --- a/web/packages/agenta-entities/tests/unit/playground-inputs-formatters.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts @@ -1,12 +1,8 @@ /** * Unit tests for the pure formatters in @agenta/entity-ui/view-types. * - * Same stopgap-location reasoning as `view-types.test.ts`: vitest runner - * lives in agenta-entities; the formatters live in @agenta/entity-ui. - * Cross-package relative import below is a test-time dep only. - * - * TODO(follow-up): Move these into agenta-entity-ui/tests/unit/ once that - * package gets its own vitest runner. + * Runs under @agenta/entity-ui's own vitest runner (added by #4393's + * vitest.config.ts). */ import {describe, expect, it} from "vitest" @@ -15,7 +11,7 @@ import { parseJsonEdit, parseYamlEdit, valueToDisplay, -} from "../../../agenta-entity-ui/src/view-types/formatters" +} from "../../src/view-types/formatters" describe("formatters: valueToDisplay", () => { describe("nullish handling", () => { diff --git a/web/packages/agenta-entities/tests/unit/view-types.test.ts b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts similarity index 89% rename from web/packages/agenta-entities/tests/unit/view-types.test.ts rename to web/packages/agenta-entity-ui/tests/unit/view-types.test.ts index 2a3667c275..9312d196e7 100644 --- a/web/packages/agenta-entities/tests/unit/view-types.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts @@ -1,18 +1,10 @@ /** * Unit tests for the view-types primitives in @agenta/entity-ui/view-types. * - * NOTE: This test file lives in @agenta/entities because that package already - * has a vitest runner wired up. The agenta-entity-ui package does not yet - * ship its own test runner. The relative import below crosses the package - * boundary deliberately to avoid: - * - Adding @agenta/entity-ui as a (test-time) dep of @agenta/entities, - * which would create a dependency cycle since entity-ui depends on - * entities at runtime. - * - Standing up a full vitest + stubs setup in agenta-entity-ui as part - * of this branch. - * - * TODO(follow-up): Move these tests into agenta-entity-ui/tests/unit/ once - * that package gets its own vitest runner. Tracked separately from this PR. + * Runs under @agenta/entity-ui's own vitest runner (added by #4393's + * vitest.config.ts). Previously these tests lived as a stopgap in + * @agenta/entities/tests/unit/ because entity-ui had no runner — that + * limitation was lifted by #4393, so the tests moved to their natural home. */ import {describe, expect, it} from "vitest" @@ -22,7 +14,7 @@ import { getDefaultViewForValue, getViewOptions, isChatMessagesArray, -} from "../../../agenta-entity-ui/src/view-types/viewTypes" +} from "../../src/view-types/viewTypes" describe("view-types: isChatMessagesArray", () => { it("detects a basic role-tagged messages array", () => { From 6a84b7aace3326cc9bdeac90af52ea84f876caa2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 01:17:58 +0200 Subject: [PATCH 09/41] chore(frontend): fix import order in SingleLayout (move workspace imports up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESLint import/order rule requires `@agenta/*` workspace imports to come before relative imports. The pre-existing `@agenta/playground-ui/adapters` import was already in the wrong place; my Step 6 additions (PlaygroundInputsBodyHost, useNewPlaygroundInputsBodyAtom) sat next to it. Move all three to the right block — no behavior change. --- .../assets/ExecutionRow/SingleLayout.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx index 9ae6153e53..643e69810c 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx @@ -26,6 +26,13 @@ import clsx from "clsx" import {useAtom, useAtomValue, useSetAtom} from "jotai" import {atomWithStorage} from "jotai/utils" +import {VariableControlAdapter} from "@agenta/playground-ui/adapters" +import {PlaygroundInputsBodyHost} from "@agenta/playground-ui/playground-inputs-body" +import { + openPlaygroundFocusDrawerAtom, + useNewPlaygroundInputsBodyAtom, +} from "@agenta/playground-ui/state" + import {usePlaygroundUIOptional} from "../../../../context/PlaygroundUIContext" import {useRepetitionResult} from "../../../../hooks/useRepetitionResult" import {getShortTestcaseId} from "../../../../utils/testcaseLabel" @@ -47,13 +54,6 @@ import { import {ExecutionRowRunControl, usePlaygroundNodeLabels} from "./shared" -import {VariableControlAdapter} from "@agenta/playground-ui/adapters" -import {PlaygroundInputsBodyHost} from "@agenta/playground-ui/playground-inputs-body" -import { - openPlaygroundFocusDrawerAtom, - useNewPlaygroundInputsBodyAtom, -} from "@agenta/playground-ui/state" - // Dismissable callout that explains what the two evaluator playground // variables represent (the application being evaluated's inputs and output). // Persisted per-user via localStorage so it doesn't reappear after the user From df52f5f9d33937f6b58330e36174de2e23d0879e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 12:20:23 +0200 Subject: [PATCH 10/41] feat(frontend): default the new playground inputs body ON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip useNewPlaygroundInputsBodyAtom default to true. The V2-aligned PlaygroundInputsBody is now the default rendering for SingleLayout's flat (non-grouped) path: bordered card per variable, granular type chips, "View as ▾" dropdown with Chat / Form / Text / Markdown / JSON / YAML, native-JSON edits. VariableControlAdapter is still used for: - the grouped evaluator layout (useGroupedLayout === true) — field ports nested under envelope sections, follow-up swap deferred. - ComparisonLayout — multi-variant side-by-side view, same pattern as SingleLayout but the swap is the next ticket per the design doc. Once ComparisonLayout is also swapped, the flag + conditional in SingleLayout can be removed entirely. --- .../src/state/featureFlags.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/state/featureFlags.ts b/web/packages/agenta-playground-ui/src/state/featureFlags.ts index 8d5db2661a..795b5aabf8 100644 --- a/web/packages/agenta-playground-ui/src/state/featureFlags.ts +++ b/web/packages/agenta-playground-ui/src/state/featureFlags.ts @@ -18,13 +18,14 @@ import {atom} from "jotai" * `PlaygroundInputsBody` component (bordered card per variable, type * chip + "View as ▾" dropdown, native JSON edits). * - * Default `false` — existing rendering is preserved. The consumer (OSS - * playground entry, an `init()` effect, a dev toggle) flips this on once - * the new UX is signed off. When the new path is the default for everyone, - * delete this flag and the conditional in `SingleLayout`. + * Default `true` — the new UX is now the playground default. Existing + * `VariableControlAdapter` is still used for grouped evaluator layouts + * (`useGroupedLayout === true`) and ComparisonLayout per the design doc's + * deferred follow-ups. When ComparisonLayout is swapped too, the flag + + * conditional in `SingleLayout` can be removed entirely. * - * Wired only for SingleLayout's flat (non-grouped) path in the initial - * cut. Grouped evaluator layouts and ComparisonLayout still use the - * adapter — see the deferred follow-ups in the approved design doc. + * Wired only for SingleLayout's flat (non-grouped) path. Grouped evaluator + * layouts and ComparisonLayout still use the adapter — see the deferred + * follow-ups in the approved design doc. */ -export const useNewPlaygroundInputsBodyAtom = atom(false) +export const useNewPlaygroundInputsBodyAtom = atom(true) From 1a5b70e5f12820d1bb99756218578de2eaebb322 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 12:45:28 +0200 Subject: [PATCH 11/41] fix(frontend): editor accepts mustache section close tags `{{/name}}` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt-editor token validator at templateVariable.ts:130 treated any `{{/...}}` as a JSON Pointer and required the first segment to be a known envelope slot. That rejected mustache section close tags like `{{/languages}}` (paired with `{{#languages}}`) as "Unknown envelope slot." Fix: short-circuit single-segment identifier-shaped paths (`/^/[a-zA-Z_][\w.]*$/`) as valid. They can't be multi-segment JSON Pointers, and in mustache they're section close tags. Multi-segment paths (`/inputs/foo`) still get the envelope-slot check. Numeric-led paths (`/123abc`) fall through and are rejected. Trade-off: legacy curly users writing `{{/input}}` (singular, typo of `{{/inputs}}`) lose the typo-detection hint at the editor. The runtime remains the source of truth — mustache renderer surfaces a clear error for unmatched close tags, and curly's `/input` lookup returns no value. Accepted because mustache is the new default and section close tags are common-path syntax. Also corrects A5 in the test plan: `{{$.geo.region}}` is by-design rejected by the validator (JSONPath must root at an envelope slot). Switched the suggested syntax to `{{$.inputs.geo.region}}` with a note about the runtime-spread vs static-validator gap. 14 tests in template-variable-validation.test.ts pin: plain names, dotted access, JSONPath envelope rooting, JSON Pointer envelope rooting, mustache section close acceptance, numeric-led rejection. --- .../wp-f-playground-mustache/test-plan.md | 490 ++++++++++++++++++ .../unit/template-variable-validation.test.ts | 105 ++++ .../src/utils/templateVariable.ts | 16 + 3 files changed, 611 insertions(+) create mode 100644 docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md create mode 100644 web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts diff --git a/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md new file mode 100644 index 0000000000..7f4d9b2c85 --- /dev/null +++ b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md @@ -0,0 +1,490 @@ +# Manual QA — Playground Mustache + Native JSON + V2 Input UX + +This branch ships: + +- **Backend** (from #4393): mustache rendering, `{{$...}}` JSONPath, frontend `template_format` widening across the editor / chat-message / schema-control surfaces, `vitest` runner in `@agenta/entity-ui`, mustache as the default for new `*_v0` workflow interfaces. +- **Frontend (this branch)**: native-JSON transport surgery in `buildEvaluatorExecutionInputs`, V2-aligned playground input cards (`PlaygroundInputsBody`) behind a feature flag, referenced/draft/unreferenced visibility rule (`playgroundInputsAtomFamily`), playground `TemplateFormatPicker` component. + +This document gives you the fixtures + prompts + scenarios needed to exercise every improvement end-to-end. + +## 0. Prerequisites + +### Branch + worktree + +You're already on `fe-feat/mustache-support` at `.claude/worktrees/fe-feat-mustache-support/`. Confirm: + +```bash +cd .claude/worktrees/fe-feat-mustache-support +git branch --show-current +# fe-feat/mustache-support +git log --oneline -3 +# Should show: ba5fa4c7b chore(frontend): reconcile post-merge ... +# 5483ec07e Merge ... feat/add-mustache-rendering ... +``` + +### Local stack + +Spin up the standard Agenta dev stack from the worktree: + +```bash +# From the worktree root +docker compose -f hosting/docker-compose.dev.yml up +# OR whatever your usual local-dev start command is +``` + +Then run the web dev server pointed at this worktree: + +```bash +cd web/oss +pnpm dev +# Open http://localhost:3000 +``` + +### Enable the V2 input UX feature flag + +The new `PlaygroundInputsBody` rendering is OFF by default (`useNewPlaygroundInputsBodyAtom = false`). To turn it on for QA, you have three options: + +**Option A — temporary code change (recommended for a QA session):** + +Edit `web/packages/agenta-playground-ui/src/state/featureFlags.ts`: + +```diff +- export const useNewPlaygroundInputsBodyAtom = atom(false) ++ export const useNewPlaygroundInputsBodyAtom = atom(true) +``` + +Revert before pushing. The default-flip is an explicit follow-up commit per the design doc. + +**Option B — runtime flip via React DevTools:** + +Open React DevTools → find any component subscribed to `useNewPlaygroundInputsBodyAtom` → use the Jotai DevTools panel to set it `true`. + +**Option C — runtime flip via console:** + +Skip C if you don't already expose the Jotai store globally. + +--- + +## 1. Upload the testset + +The fixture lives at `docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json` (same folder as this doc). + +It contains 3 rows (Vanuatu / Kiribati / Switzerland) with deliberately varied column types: + +| Column | Type | Purpose | +|---|---|---| +| `country` | string | plain `{{name}}` mustache substitution | +| `population_thousands` | number | type-chip → `NUMBER`; native-number transport | +| `is_island_nation` | boolean | type-chip → `BOOLEAN`; Switch widget in text mode | +| `geo` | object (nested 2 levels) | mustache `{{geo.region}}` + `{{geo.coordinates.lat}}` — the headline test | +| `languages` | array of strings | Form view; mustache section iteration `{{#languages}}…{{/languages}}` | +| `metadata` | **string that contains JSON** | gap-04: must STAY a string, not be parsed | +| `messages` | array of role-tagged objects | Chat view; tests `ChatMessageList` integration | +| `correct_answer` | string | optional ground-truth column for the evaluator scenario | +| `notes` | string | **unused by default** — exercises the unreferenced-columns footer | + +### Upload steps + +1. In the Agenta web UI, go to **Testsets**. +2. Click **Create new testset** → **Upload file**. +3. Choose the `testset.json` file from the path above. +4. Optional: rename to something like `Mustache QA`. +5. Click **Upload**. +6. Confirm: row count = 3, columns include `country`, `geo`, `messages`, `metadata`, etc. + +**Sanity check — gap-04 in the testset editor:** + +- Open the testset → open the Vanuatu row in the drawer. +- `metadata` should show as type chip **`STRING`** (NOT `OBJECT`), even though its value looks like JSON. If the chip says `STRING`, gap-04 is alive end-to-end on the testset side. +- `geo` should show as **`OBJECT`** with nested `region` / `subregion` / `coordinates` rows. +- `messages` should show as **`MESSAGES`** (or render with the chat editor). + +--- + +## 2. Create the test app (completion-mode mustache) + +The simplest exerciser is a custom completion app whose prompt uses mustache. Newly created apps now default `template_format: "mustache"` (WP-B3 in `*_v0` interfaces). + +1. Go to **Apps** → **Create new app** → **From template** → pick a completion-mode template (e.g. `completion`). +2. Name it `Mustache QA — completion`. +3. Open the new app in the playground. + +### Verify template_format defaults + +Open the variant config panel (the prompt config side, **not** the generations side). Either: + +- Look at the `parameters.prompt.template_format` field, OR +- Open the drawer (focus a prompt) — the new `template_format` dropdown (shipped by #4393's `PromptSchemaControl`) should show **"Prompt Syntax: Mustache"** selected. + +If the dropdown shows curly/jinja2/fstring instead, you're on an existing app that pre-dates the WP-B3 default flip. Either accept that (it works fine, just not mustache) or create a brand-new app. + +### Attach the testset + +1. In the playground, **Generations** panel → **Load testset** → pick `Mustache QA`. +2. You should see one card per testset row (Vanuatu / Kiribati / Switzerland). + +--- + +## 3. Scenarios + +Each scenario lists: the prompt to paste, what to verify, and how to verify. + +### A — Mustache rendering (from #4393) + +These exercise WP-B3's backend renderer + the `extractTemplateVariables` widening shipped by #4393. + +#### A1 — Plain variable substitution + +**Prompt** (paste into the prompt editor's user/system message): + +``` +The country in scope is {{country}}. +``` + +- Run against the Vanuatu testcase. +- **Expected rendered prompt at the backend:** `"The country in scope is Vanuatu."` +- Verify via: the response trace (look at the rendered prompt input the LLM saw). + +#### A2 — Nested object access (the headline mustache feature) + +**Prompt:** + +``` +{{country}} is in the {{geo.region}} region, specifically {{geo.subregion}}. +``` + +- Run against Vanuatu. +- **Expected:** `"Vanuatu is in the Pacific Islands region, specifically Western Melanesia."` +- This is the test that should have FAILED before this branch (transport stringified `geo`, so the renderer received `"{\"region\":\"...\"}"` and `geo.region` resolved to nothing). It must pass now. + +#### A3 — Deep nested access + +**Prompt:** + +``` +{{country}} sits at coordinates lat={{geo.coordinates.lat}}, lng={{geo.coordinates.lng}}. +``` + +- Run against Vanuatu. +- **Expected:** `"Vanuatu sits at coordinates lat=-15.376, lng=166.959."` + +#### A4 — Whole-object insertion (compact JSON) + +**Prompt:** + +``` +Full geo info: {{geo}}. +``` + +- Run against Vanuatu. +- **Expected rendered:** `Full geo info: {"region":"Pacific Islands","subregion":"Western Melanesia","coordinates":{"lat":-15.376,"lng":166.959}}.` +- Per RFC: whole-object insertion renders as compact JSON. + +#### A5 — JSONPath escape hatch + +**Prompt:** + +``` +Region (via JSONPath): {{$.inputs.geo.region}}. +First language: {{$.inputs.languages[0]}}. +``` + +- Run against Vanuatu. +- **Expected:** `Region (via JSONPath): Pacific Islands. First language: en.` +- `{{$...}}` is pre-rendered before the mustache pass; values are substituted as inert text. +- **Important:** JSONPath must root at a known envelope slot (`inputs`, `outputs`, `parameters`, `testcase`, `trace`, `revision`). The editor's validator (in `@agenta/shared/utils/templateVariable.ts`) flags `{{$.geo.region}}` as invalid because the runtime spread isn't visible to the static validator. Use `{{$.inputs.geo.region}}` for the validator-approved form, or stick with plain dotted access `{{geo.region}}` — equivalent at runtime. + +#### A6 — Mustache section iteration + +**Prompt:** + +``` +Languages: {{#languages}}{{.}} {{/languages}} +``` + +- Run against Vanuatu. +- **Expected:** `Languages: en bi fr ` (trailing space before the closing). + +#### A7 — gap-04: JSON-shaped string stays a string + +**Prompt:** + +``` +Raw metadata: {{metadata}}. +``` + +- Run against Vanuatu. +- **Expected:** `Raw metadata: {"source":"trace","trace_id":"vu-001","latency_ms":520,"confidence":"high"}.` +- The value is INSERTED AS A STRING — not parsed and re-stringified. If you try `{{metadata.source}}` (next scenario), it should NOT resolve. + +#### A8 — Negative test: dotted access on a string + +**Prompt:** + +``` +Source field: {{metadata.source}}. +``` + +- Run against Vanuatu. +- **Expected:** an unresolved-variable error (or empty substitution) — `metadata` is a string, not an object; mustache's dotted-name traversal can't pierce a string. The runtime should treat this as an unresolved tag and surface a clean error. + +### B — Native JSON transport (from this branch, Step 1) + +Verifies `buildEvaluatorExecutionInputs` passes native types through to evaluators. + +#### B1 — Inspect the evaluator request body + +1. Open the playground for an evaluator-equipped variant (or set up an evaluator chain — see §4). +2. Open browser DevTools → **Network** tab. +3. Trigger a run on Vanuatu. +4. Find the request to the evaluator endpoint (look for `/services/evaluators/` or similar). +5. Inspect the **request body** → `inputs` field. + +**Expected:** + +```json +{ + "inputs": { + "geo": { + "region": "Pacific Islands", + "subregion": "Western Melanesia", + "coordinates": {"lat": -15.376, "lng": 166.959} + }, + "languages": ["en", "bi", "fr"], + "metadata": "{\"source\":\"trace\",...}", + "country": "Vanuatu", + ... + } +} +``` + +**Critical:** `inputs.geo` is a JSON OBJECT (not a string `"{\"region\":\"Pacific Islands\",...}"`). `inputs.languages` is a JSON ARRAY (not a string `"[\"en\",\"bi\",\"fr\"]"`). `inputs.metadata` IS a string (gap-04 invariant — preserves user intent). + +#### B2 — Mustache-aware evaluator + +If you have an LLM-as-a-judge evaluator wired into the chain, set its prompt to: + +``` +Your task: check whether the model's answer mentions the {{geo.region}} region. +Model answer: {{prediction}}. +Expected capital: {{correct_answer}}. +Reply YES or NO. +``` + +Set `template_format: "mustache"` and `correct_answer_key: "correct_answer"` on the evaluator. + +- Run against Vanuatu. +- **Expected:** evaluator receives `inputs.geo` as a native object (verified in B1), then mustache resolves `{{geo.region}}` to `"Pacific Islands"`. Before this branch, `geo` arrived as a string and the dotted access failed. + +### C — V2 input UX (feature-flag on) + +Make sure the feature flag is ON (per §0). Reload the playground. + +#### C1 — Bordered cards per variable + +- Open a generation card on the Vanuatu testcase. +- **Expected:** each variable is a bordered card with a header (variable name + type chip + "View as ▾" dropdown) and a body (the editor). + +#### C2 — Type chip vocabulary + +For Vanuatu, you should see chips like: + +| Variable | Chip | +|---|---| +| `country` | `STRING` | +| `population_thousands` | `NUMBER` | +| `is_island_nation` | `BOOLEAN` | +| `geo` | `OBJECT` | +| `languages` | `ARRAY` | +| `metadata` | `STRING` (gap-04 — even though it looks like JSON) | +| `messages` | `MESSAGES` (chat-detected) | + +#### C3 — View-as dropdown options + +Click "View as ▾" on each variable. Confirm the options scale to its kind: + +| Variable kind | Options offered | Default | +|---|---|---| +| string (`country`, `metadata`) | Text, Markdown, JSON, YAML | Text | +| boolean (`is_island_nation`) | Text, JSON, YAML | Text | +| object (`geo`) | Form, JSON, YAML | Form | +| array (`languages`) | Form, JSON, YAML | Form | +| chat (`messages`) | Chat, JSON, YAML | Chat | + +#### C4 — Form mode (object) + +- `geo`'s default view is Form. +- **Expected:** nested fields render inline with their own type chips — `region STRING`, `subregion STRING`, `coordinates OBJECT` → `lat NUMBER`, `lng NUMBER`. +- The 2-level nesting should indent behind a left rail. + +#### C5 — Chat mode (messages) + +- `messages`'s default view is Chat. +- **Expected:** `ChatMessageList` renders with one message per array entry, each with role + content. + +#### C6 — JSON view round-trip + +- Switch `geo` to **JSON** view. +- **Expected:** code editor with pretty-printed JSON (2-space indent, syntax highlight). +- Edit the JSON (e.g. change `region` to `"Oceania"`) → tab out / blur. +- Re-open the variable in the drawer (or switch back to Form view). +- **Expected:** the edit persisted natively (geo.region is now `"Oceania"`). + +#### C7 — YAML view round-trip + +- Switch `geo` to **YAML** view. +- **Expected:** YAML dump with `region:`, `subregion:`, nested `coordinates:`. +- Edit the YAML → tab out. +- **Expected:** parses on edit, stores native value. + +#### C8 — gap-04 in the playground + +- Open `metadata`'s "View as ▾" → switch to **JSON** view. +- **Expected:** the editor shows the JSON inside the string, pretty-printed (because `valueToDisplay(...)`'s json mode attempts `JSON.parse(value)` for strings). +- Make a small edit (change `confidence` to `"medium"`). +- **Expected:** on blur, the value is STILL stored as a string (not parsed and stored as an object). Switch back to Text view to confirm — the value is the raw string text. Type chip stays `STRING`. + +### D — Visibility rule + +#### D1 — Draft variable (referenced + not on testcase) + +- In the prompt editor, add a reference to a variable that doesn't exist on the testcase, e.g. `{{iso_code}}`. +- **Expected:** a new variable card appears in the generations panel with: + - Name: `iso_code` + - Type chip: ambiguous / inferred from undefined → likely `STRING` chip (default) + - A small `draft` text tag in the header + - Empty body (no value yet) + +#### D2 — Draft persistence + +- Type a value into the draft card (e.g. `"VU"`). +- Click **Run**. +- **Expected:** the testcase now carries `iso_code: "VU"`. Reload the page; the `iso_code` column persists on the Vanuatu row. + +#### D3 — Unreferenced columns footer + +- If your prompt does NOT reference `notes`, you should see a footer below all variable cards: + > **▶ 1 unused testcase column hidden because the prompt does not reference them.** +- Click the footer to expand. +- **Expected:** a collapsed card for `notes` appears below. +- If the count is different (e.g. you removed other references), the count updates live as you edit the prompt. + +#### D4 — Referenced ↔ unreferenced transition + +- Add `{{notes}}` to the prompt. +- **Expected:** the `notes` card moves from the footer (unreferenced) up to the expanded cards (referenced). Footer count drops to 0 (footer disappears). +- Remove `{{notes}}` from the prompt. +- **Expected:** `notes` moves back to the unreferenced footer. + +### E — End-to-end mustache + native JSON + +The full integration test — the user-visible win for this whole branch. + +**Prompt:** + +``` +You are a geography research assistant. + +The country in scope is {{country}}. Its region is {{geo.region}}, subregion is {{geo.subregion}}. +Population is {{population_thousands}} thousand. The country speaks: {{#languages}}{{.}} {{/languages}}. + +Coordinates: lat {{geo.coordinates.lat}}, lng {{geo.coordinates.lng}}. + +Reply with a one-sentence answer to the most recent user question. +``` + +1. Confirm `template_format: "mustache"` on the variant (see §2). +2. Set `messages` (or pass `messages` from testcase) as the chat history. +3. Run against Vanuatu. + +**Expected (rendered prompt seen by the LLM):** + +``` +You are a geography research assistant. + +The country in scope is Vanuatu. Its region is Pacific Islands, subregion is Western Melanesia. +Population is 320 thousand. The country speaks: en bi fr . + +Coordinates: lat -15.376, lng 166.959. + +Reply with a one-sentence answer to the most recent user question. +``` + +(Plus the `messages` array tacked on by chat mode.) + +**The model's response** should answer the most recent user message — for Vanuatu, "What is the capital of Vanuatu and its ISO 3166-1 alpha-2 code?" → expected response: something like "Port Vila (VU)". + +If the rendered prompt has bare `{{country}}` / `{{geo.region}}` placeholders (not substituted), template_format is wrong or transport is broken. +If `geo.region` resolves to an empty string, transport is broken (geo arrived as a stringified `"{...}"`, mustache dotted access can't pierce it). + +--- + +## 4. Optional — Evaluator chain setup (for B2 + advanced transport tests) + +Quick recipe to add an LLM-as-a-judge evaluator on top of the test app: + +1. In the playground, **Add** → **Evaluator** → pick `auto_ai_critique` (or `auto_ai_critique_v0`). +2. Wire its inputs: + - `prediction` ← upstream app output + - `correct_answer_key` setting = `"correct_answer"` (so `inputs.correct_answer` resolves from the testcase) +3. Evaluator prompt template (mustache): + ``` + Question: {{messages}} + Expected answer (region context: {{geo.region}}): {{correct_answer}} + Model answer: {{prediction}} + Score: 1 if the model's answer mentions the expected capital, 0 otherwise. + ``` +4. Run against Vanuatu. + +**Critical verification (B1 redux):** check the evaluator's request body. `inputs.geo` must arrive as native object. If it's a stringified JSON, the transport surgery regressed. + +--- + +## 5. Regression checks (existing apps) + +These ensure we didn't break anything that worked before. + +### R1 — Legacy curly app + +- Open any pre-existing app whose `template_format` is `curly`. +- Confirm the picker (in the drawer) shows **Curly** as selected with a "legacy" hint badge. +- Run the existing test cases. +- **Expected:** no behavior change. Curly's literal-key-first lookup still works. + +### R2 — Legacy fstring app + +- Same as R1 but for `fstring`. + +### R3 — Feature flag OFF + +- Set `useNewPlaygroundInputsBodyAtom` back to `false` (revert the temp edit, or flip via devtools). +- Reload the playground. +- **Expected:** the playground falls back to the old `VariableControlAdapter` rendering (borderless per-variable cells, no type chips, no "View as ▾"). No regression. + +--- + +## 6. What's NOT tested by this plan (deferred follow-ups) + +- **ComparisonLayout** — multi-variant side-by-side view still uses `VariableControlAdapter`. Same swap pattern applies; deferred per design doc. +- **Grouped evaluator layout** — `useGroupedLayout === true` branch (evaluator with extracted field ports under envelope sections) still uses `VariableControlAdapter`. +- **TemplateFormatPicker placement in the playground** — the component is built but not yet placed in a specific OSS prompt-config surface (Open Q2 — needs design-team sign-off). The drawer's picker (from #4393) IS testable. +- **Default-flip of the feature flag** — small follow-up commit after you sign off the new UX. + +--- + +## 7. Quick "did everything pass?" checklist + +- [ ] **A1-A6** mustache scenarios all return correctly rendered prompts at the LLM. +- [ ] **A7** `metadata` string is inserted as-is (not parsed). +- [ ] **A8** `{{metadata.source}}` fails with a clear unresolved-variable error. +- [ ] **B1** Network tab shows `inputs.geo` as a native object (not a string). +- [ ] **B2** Mustache-aware evaluator works against the native-JSON inputs. +- [ ] **C1-C5** Each variable type renders with the right chip + view-as options. +- [ ] **C6-C7** JSON ↔ YAML edit round-trips preserve native types. +- [ ] **C8** gap-04 holds: `metadata` stays a STRING even after JSON-view edits. +- [ ] **D1-D2** Draft variables show + persist correctly. +- [ ] **D3-D4** Unreferenced footer updates live as the prompt changes. +- [ ] **E** End-to-end mustache + native JSON renders the expected prompt text. +- [ ] **R1-R3** Legacy formats + feature-flag-off paths unchanged. + +If all of the above pass, this branch is good to ship. diff --git a/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts new file mode 100644 index 0000000000..e840001c76 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts @@ -0,0 +1,105 @@ +/** + * Unit tests for validateTemplateVariable. + * + * The validator decides whether a `{{...}}` token gets the "valid" style + * (green) or the "invalid" red-dashed treatment in the Lexical editor. It + * lives in `@agenta/shared/utils/templateVariable.ts`. + * + * Why these tests live in agenta-entities: agenta-shared has no vitest + * runner of its own. Same stopgap pattern as the other tests in this + * folder. Cross-package relative import below is a test-time dep only. + */ +import {describe, expect, it} from "vitest" + +import {validateTemplateVariable} from "../../../agenta-shared/src/utils/templateVariable" + +describe("validateTemplateVariable", () => { + describe("plain names + dot notation", () => { + it("accepts a plain variable name", () => { + expect(validateTemplateVariable("country")).toEqual({valid: true}) + }) + + it("accepts dotted access", () => { + expect(validateTemplateVariable("geo.region")).toEqual({valid: true}) + expect(validateTemplateVariable("geo.coordinates.lat")).toEqual({valid: true}) + }) + + it("rejects an empty expression", () => { + expect(validateTemplateVariable("").valid).toBe(false) + }) + + it("rejects expressions with consecutive separators", () => { + expect(validateTemplateVariable("a..b").valid).toBe(false) + expect(validateTemplateVariable("/a//b").valid).toBe(false) + }) + }) + + describe("JSONPath ($-prefixed)", () => { + it("accepts when rooted at a known envelope slot", () => { + expect(validateTemplateVariable("$.inputs.country").valid).toBe(true) + expect(validateTemplateVariable("$.outputs.answer").valid).toBe(true) + expect(validateTemplateVariable("$.parameters.temperature").valid).toBe(true) + expect(validateTemplateVariable("$.trace.span_id").valid).toBe(true) + }) + + it("rejects when rooted at an unknown segment", () => { + const result = validateTemplateVariable("$.geo.region") + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/Unknown envelope slot/i) + }) + + it("suggests the nearest envelope slot on typos", () => { + const result = validateTemplateVariable("$.input.country") + expect(result.valid).toBe(false) + expect(result.suggestion).toBe("inputs") + }) + }) + + describe("JSON Pointer (/-prefixed) — multi-segment", () => { + it("accepts multi-segment paths rooted at a known slot", () => { + expect(validateTemplateVariable("/inputs/country").valid).toBe(true) + expect(validateTemplateVariable("/outputs/answer/iso").valid).toBe(true) + }) + + it("rejects multi-segment paths rooted at an unknown slot", () => { + const result = validateTemplateVariable("/input/country") + expect(result.valid).toBe(false) + expect(result.suggestion).toBe("inputs") + }) + }) + + describe("mustache section close tags (single-segment /-prefixed)", () => { + // {{#languages}}...{{/languages}} — the close tag is a mustache section + // marker, not a JSON Pointer. Before this branch's fix, the validator + // mistook it for a JSON Pointer and rejected it because `languages` + // isn't in KNOWN_ENVELOPE_SLOTS. + + it("accepts `/identifier` (simple mustache section close)", () => { + expect(validateTemplateVariable("/languages").valid).toBe(true) + expect(validateTemplateVariable("/items").valid).toBe(true) + expect(validateTemplateVariable("/users").valid).toBe(true) + }) + + it("accepts `/dotted.identifier` (mustache dotted section close)", () => { + expect(validateTemplateVariable("/profile.name").valid).toBe(true) + expect(validateTemplateVariable("/a.b.c").valid).toBe(true) + }) + + it("accepts `/_underscored`", () => { + expect(validateTemplateVariable("/_private").valid).toBe(true) + }) + + it("does NOT short-circuit multi-segment JSON Pointers", () => { + // Multi-segment paths still go through the envelope-slot check. + const result = validateTemplateVariable("/wrong/path") + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/Unknown envelope slot/i) + }) + + it("rejects identifier-shaped paths that aren't valid identifiers (numeric leading)", () => { + // Numeric-leading isn't a valid mustache identifier or an envelope + // slot — fall through to JSON Pointer validation and reject. + expect(validateTemplateVariable("/123abc").valid).toBe(false) + }) + }) +}) diff --git a/web/packages/agenta-shared/src/utils/templateVariable.ts b/web/packages/agenta-shared/src/utils/templateVariable.ts index 0a2a8f988c..b1a2b2b781 100644 --- a/web/packages/agenta-shared/src/utils/templateVariable.ts +++ b/web/packages/agenta-shared/src/utils/templateVariable.ts @@ -128,6 +128,22 @@ export function validateTemplateVariable(expr: string): TemplateVariableValidati } if (expr.startsWith("/")) { + // Mustache section close tags look like `{{/identifier}}` — + // single-segment, identifier-shaped, with no further `/`. JSON + // Pointer paths to envelope slots are also single-segment (e.g. + // `/inputs`), and we can't tell mustache vs JSON Pointer without + // format context here. Pragmatic disambiguation: single-segment + // identifier-shaped paths are accepted unconditionally (the runtime + // is the source of truth — if the close tag has no matching open, + // the mustache renderer surfaces a clear error at render time; if + // the user meant a legacy JSON Pointer to `/input`, the typo + // detection was already a "best effort" hint). Multi-segment JSON + // Pointers (`/inputs/foo/bar`) still get the envelope-slot check. + const isSingleSegmentIdentifier = /^\/[a-zA-Z_][\w.]*$/.test(expr) + if (isSingleSegmentIdentifier) { + return {valid: true} + } + const tokens = expr.split("/").filter(Boolean) if (tokens.length === 0) { return { From 030f43863a27afb8cf1782d17fb0a24b4248459a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 12:46:37 +0200 Subject: [PATCH 12/41] docs(testing): add mustache + native-JSON QA testset fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-row JSON array (Vanuatu / Kiribati / Switzerland) with 9 columns covering every type the playground UX has to handle: - string : country, correct_answer (plain {{name}} subst.) - number : population_thousands (NUMBER chip, native transport) - boolean : is_island_nation (BOOLEAN chip, Switch widget) - object : geo (2-level nested — the headline mustache test) - array : languages (ARRAY chip, section iteration) - string-as-JSON : metadata (gap-04: stays STRING, not parsed) - messages : chat-shaped role-tagged array (MESSAGES chip, Chat view) - unused-column : notes (drives the unreferenced-columns footer) Uploads via the testset UI in bare-array form (matches the /simple/testsets/upload endpoint, NOT the {name, csvdata} wrapper). Referenced by test-plan.md (same folder) §1 (upload) and the scenarios in §3. --- .../wp-f-playground-mustache/testset.json | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json diff --git a/docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json new file mode 100644 index 0000000000..654cb313e0 --- /dev/null +++ b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json @@ -0,0 +1,83 @@ +[ + { + "country": "Vanuatu", + "population_thousands": 320, + "is_island_nation": true, + "geo": { + "region": "Pacific Islands", + "subregion": "Western Melanesia", + "coordinates": { + "lat": -15.376, + "lng": 166.959 + } + }, + "languages": ["en", "bi", "fr"], + "metadata": "{\"source\":\"trace\",\"trace_id\":\"vu-001\",\"latency_ms\":520,\"confidence\":\"high\"}", + "messages": [ + { + "role": "system", + "content": "You are a geography research assistant with access to a country lookup tool. Cite ISO codes when available." + }, + { + "role": "user", + "content": "What is the capital of Vanuatu and its ISO 3166-1 alpha-2 code?" + }, + { + "role": "assistant", + "content": "Port Vila is the capital of Vanuatu. Its ISO 3166-1 alpha-2 code is VU." + } + ], + "correct_answer": "Port Vila", + "notes": "Vanuatu became independent in 1980. Unused-by-default to exercise the unreferenced-columns footer." + }, + { + "country": "Kiribati", + "population_thousands": 120, + "is_island_nation": true, + "geo": { + "region": "Pacific Islands", + "subregion": "Micronesia", + "coordinates": { + "lat": 1.451, + "lng": 172.971 + } + }, + "languages": ["en", "gil"], + "metadata": "{\"source\":\"trace\",\"trace_id\":\"ki-001\",\"latency_ms\":480,\"confidence\":\"high\"}", + "messages": [ + { + "role": "system", + "content": "You are a geography research assistant with access to a country lookup tool. Cite ISO codes when available." + }, + { + "role": "user", + "content": "What is the capital of Kiribati?" + } + ], + "correct_answer": "South Tarawa", + "notes": "Kiribati straddles the equator and the international date line." + }, + { + "country": "Switzerland", + "population_thousands": 8700, + "is_island_nation": false, + "geo": { + "region": "Europe", + "subregion": "Western Europe", + "coordinates": { + "lat": 46.818, + "lng": 8.227 + } + }, + "languages": ["de", "fr", "it", "rm"], + "metadata": "{\"source\":\"trace\",\"trace_id\":\"ch-001\",\"latency_ms\":410,\"confidence\":\"high\"}", + "messages": [ + { + "role": "user", + "content": "What is the capital of Switzerland?" + } + ], + "correct_answer": "Bern", + "notes": "Switzerland is famous for its political neutrality and four national languages." + } +] From e742a0345b5b7a87ecefb9cd280799e144106cda Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 12:57:01 +0200 Subject: [PATCH 13/41] fix(frontend): validator accepts JSONPath rooted at testcase columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC's canonical mustache JSONPath examples use a testcase top-level column as the root (e.g. `{{$.profile.name}}` against a `profile` column that's spread into the render context). The editor's validator at templateVariable.ts:108 was rejecting these as "Unknown envelope slot", because it required the root segment to be one of {inputs, outputs, parameters, testcase, trace, revision}. That contradicts the RFC. Fix: relax the JSONPath check — accept any root that ISN'T a near-miss typo of an envelope slot. Typo detection stays as an actionable hint (e.g. `$.input.country` → suggests `inputs`), but legit testcase columns (`$.geo.region`, `$.profile.name`, `$.country`) now pass through. The bare root `{{$}}` (whole context as compact JSON) is also now accepted; it was previously rejected for having no segments. RFC docs this as canonical syntax for serializing the whole context. JSON Pointer rule unchanged. Per RFC, JSON Pointer is legacy-curly only; mustache uses `$.` JSONPath. Reverts the A5 test-plan workaround — `{{$.geo.region}}` is now what the user should type, matching the RFC examples. Updated `template-variable-validation.test.ts` from 14 → 19 tests: adds the testcase-column rooting cases, the bare-`$` case, and the typo-hint mention of the testcase-column escape. --- .../wp-f-playground-mustache/test-plan.md | 6 ++-- .../unit/template-variable-validation.test.ts | 36 +++++++++++++++---- .../src/utils/templateVariable.ts | 33 ++++++++++++----- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md index 7f4d9b2c85..9da69ce4c5 100644 --- a/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md +++ b/docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md @@ -185,14 +185,14 @@ Full geo info: {{geo}}. **Prompt:** ``` -Region (via JSONPath): {{$.inputs.geo.region}}. -First language: {{$.inputs.languages[0]}}. +Region (via JSONPath): {{$.geo.region}}. +First language: {{$.languages[0]}}. ``` - Run against Vanuatu. - **Expected:** `Region (via JSONPath): Pacific Islands. First language: en.` - `{{$...}}` is pre-rendered before the mustache pass; values are substituted as inert text. -- **Important:** JSONPath must root at a known envelope slot (`inputs`, `outputs`, `parameters`, `testcase`, `trace`, `revision`). The editor's validator (in `@agenta/shared/utils/templateVariable.ts`) flags `{{$.geo.region}}` as invalid because the runtime spread isn't visible to the static validator. Use `{{$.inputs.geo.region}}` for the validator-approved form, or stick with plain dotted access `{{geo.region}}` — equivalent at runtime. +- Per the RFC, the JSONPath root can be a testcase top-level column (keys are spread into the render context). The editor accepts `{{$.geo.region}}` as well as `{{$.inputs.geo.region}}` — both resolve at runtime. The validator only flags actual typos of envelope slot names (e.g. `$.input.country` → suggests `inputs`). #### A6 — Mustache section iteration diff --git a/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts index e840001c76..b2ace04bcb 100644 --- a/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts +++ b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts @@ -42,16 +42,38 @@ describe("validateTemplateVariable", () => { expect(validateTemplateVariable("$.trace.span_id").valid).toBe(true) }) - it("rejects when rooted at an unknown segment", () => { - const result = validateTemplateVariable("$.geo.region") - expect(result.valid).toBe(false) - expect(result.reason).toMatch(/Unknown envelope slot/i) + it("accepts when rooted at a testcase top-level column (RFC: keys are spread)", () => { + // Per RFC: testcase top-level keys are spread into the render + // context, so `{{$.profile.name}}` resolves against the spread + // `profile` key. The validator must not gate this. + expect(validateTemplateVariable("$.geo.region").valid).toBe(true) + expect(validateTemplateVariable("$.profile.name").valid).toBe(true) + expect(validateTemplateVariable("$.profile.tags[0]").valid).toBe(true) + expect(validateTemplateVariable("$.country").valid).toBe(true) }) - it("suggests the nearest envelope slot on typos", () => { + it("accepts the bare root `$` (whole context as compact JSON)", () => { + expect(validateTemplateVariable("$").valid).toBe(true) + }) + + it("rejects when the root looks like a typo of an envelope slot", () => { + // `input` → `inputs`, `out` → `outputs` etc. These are the + // actionable typo hints the editor surfaces. + const inputTypo = validateTemplateVariable("$.input.country") + expect(inputTypo.valid).toBe(false) + expect(inputTypo.suggestion).toBe("inputs") + + const outTypo = validateTemplateVariable("$.out.answer") + expect(outTypo.valid).toBe(false) + expect(outTypo.suggestion).toBe("outputs") + }) + + it("rejects with a hint that mentions testcase-column escape", () => { const result = validateTemplateVariable("$.input.country") - expect(result.valid).toBe(false) - expect(result.suggestion).toBe("inputs") + expect(result.reason).toMatch(/looks like a typo/i) + // The reason text should mention the testcase-column form so the + // user knows how to express it if `input` is actually their column. + expect(result.reason).toMatch(/testcase column/i) }) }) diff --git a/web/packages/agenta-shared/src/utils/templateVariable.ts b/web/packages/agenta-shared/src/utils/templateVariable.ts index b1a2b2b781..209e27d582 100644 --- a/web/packages/agenta-shared/src/utils/templateVariable.ts +++ b/web/packages/agenta-shared/src/utils/templateVariable.ts @@ -73,7 +73,13 @@ export interface TemplateVariableValidation { /** * Validate a template placeholder against the envelope schema. * - * - JSONPath / JSON Pointer: the root segment MUST be a known envelope slot. + * - JSONPath (`$.`) / JSON Pointer (`/`): the root segment is + * permissive — it CAN be an envelope slot (`inputs`, `outputs`, ...) or + * a testcase top-level column (which gets spread into the render context + * per the RFC, so `{{$.profile.name}}` resolves against the spread `profile` + * key). The validator flags the root as invalid ONLY when it looks like a + * typo of an envelope slot (e.g. `input` → `inputs`, `out` → `outputs`) — + * that's the typo detection hint we keep around. Otherwise, accept. * - Plain names and dot-notation: permissive (no envelope prefix, can't * validate structurally without more context). * @@ -111,19 +117,28 @@ export function validateTemplateVariable(expr: string): TemplateVariableValidati .split(/[.[\]'"]/) .filter(Boolean) if (tokens.length === 0) { - return { - valid: false, - reason: `JSONPath root has no envelope slot. Expected one of: ${knownList}.`, - } + // `{{$}}` (whole context as compact JSON) is valid mustache + // JSONPath. `{{$.}}` or similar empties are caught by the + // hasEmptySegment check above. + return {valid: true} } - if (!KNOWN_ENVELOPE_SLOTS.has(tokens[0])) { - const suggestion = suggestEnvelopeSlot(tokens[0]) + // Per the RFC, the JSONPath root can be either an envelope slot + // (`inputs`, `outputs`, ...) OR a testcase column (testcase top-level + // keys are spread into the render context, so `{{$.profile.name}}` + // resolves against the spread `profile`). The validator only flags + // when the root looks like a near-miss TYPO of an envelope slot — + // that's the actionable signal the editor can give without context. + const first = tokens[0] + if (KNOWN_ENVELOPE_SLOTS.has(first)) return {valid: true} + const suggestion = suggestEnvelopeSlot(first) + if (suggestion) { return { valid: false, - reason: `Unknown envelope slot \`${tokens[0]}\`. Must root at one of: ${knownList}.`, - ...(suggestion ? {suggestion} : {}), + reason: `\`${first}\` looks like a typo for envelope slot \`${suggestion}\`. (If \`${first}\` is a testcase column, use \`{{${first}.${tokens.slice(1).join(".") || "..."}}}\` or rename to avoid the resemblance.)`, + suggestion, } } + // Not a slot, not a typo — assume it's a testcase-spread key. return {valid: true} } From 56bbed71d7721985007ed4c484587ff058aba4b2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 14:08:46 +0200 Subject: [PATCH 14/41] fix(frontend): extractor skips mustache block markers; parser handles testcase-spread roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes to keep input-port discovery aligned with the RFC's spread semantics: 1. extractTemplateVariables now skips mustache block markers — they're structural syntax, not variables, and shouldn't produce phantom input ports: - `{{#name}}` / `{{^name}}` — section opens / inverted sections - `{{/name}}` — section closes - `{{!comment}}` — comments - `{{> partial}}` — partials (also rejected at render time) - `{{.}}` — the implicit iterator Filter applied for all formats (mustache uses these natively; curly doesn't but the filter is defensive against paste-from-mustache). 2. parseTemplateExpression's JSONPath branch now routes non-envelope first segments through the testcase-spread path: `{{$.geo.region}}` parses as {envelope: "inputs", key: "geo", subPath: "region"}, equivalent to {{$.inputs.geo.region}}. Matches RFC: "testcase top-level keys are spread into the render context, so `{{$.profile. name}}` resolves against the spread `profile`." Same shape already in place for plain dot-notation (`profile.name`); this change brings JSONPath in line. Test updates: - port-helpers.test.ts: `$.invalid.x` no longer rejected — replaced with `$.input.x` (still rejected as envelope-slot typo). Added a positive test for the testcase-spread case. - New extract-template-variables.test.ts: 15 tests pinning block- marker filtering across mustache / curly / fstring. Full @agenta/entities suite: 385 tests pass. --- .../src/runnable/portHelpers.ts | 32 ++++++- .../agenta-entities/src/runnable/utils.ts | 16 +++- .../unit/extract-template-variables.test.ts | 95 +++++++++++++++++++ .../tests/unit/port-helpers.test.ts | 23 ++++- 4 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts diff --git a/web/packages/agenta-entities/src/runnable/portHelpers.ts b/web/packages/agenta-entities/src/runnable/portHelpers.ts index bf5bfacb9b..b969febb8a 100644 --- a/web/packages/agenta-entities/src/runnable/portHelpers.ts +++ b/web/packages/agenta-entities/src/runnable/portHelpers.ts @@ -191,11 +191,35 @@ function parseTemplateExpression(expr: string): ParsedTemplateExpression { const parseSegments = (segments: string[]): ParsedTemplateExpression => { if (segments.length === 0) return {envelope: "inputs", key: ""} - if (segments.length === 1) return {envelope: segments[0], key: ""} + + const first = segments[0] + const firstIsEnvelope = KNOWN_ENVELOPE_SLOTS.has(first) + + if (segments.length === 1) { + // `$.inputs` / `$.outputs` — envelope-only reference. + if (firstIsEnvelope) return {envelope: first, key: ""} + // `$.profile` — testcase-spread key. Per the RFC, testcase + // top-level columns are spread into the render context, so + // they live implicitly under the `inputs` envelope. Treating + // the first segment as the key under `inputs` keeps port + // discovery consistent with envelope-rooted writes. + return {envelope: "inputs", key: first} + } + + if (firstIsEnvelope) { + return { + envelope: first, + key: segments[1], + subPath: segments.length > 2 ? segments.slice(2).join(".") : undefined, + } + } + // Testcase-spread key with a sub-path: `$.profile.name` → + // `{envelope: "inputs", key: "profile", subPath: "name"}`. The + // testcase spread makes this equivalent to `$.inputs.profile.name`. return { - envelope: segments[0], - key: segments[1], - subPath: segments.length > 2 ? segments.slice(2).join(".") : undefined, + envelope: "inputs", + key: first, + subPath: segments.length > 1 ? segments.slice(1).join(".") : undefined, } } diff --git a/web/packages/agenta-entities/src/runnable/utils.ts b/web/packages/agenta-entities/src/runnable/utils.ts index 8f28ab6ddc..22a14aea21 100644 --- a/web/packages/agenta-entities/src/runnable/utils.ts +++ b/web/packages/agenta-entities/src/runnable/utils.ts @@ -506,7 +506,18 @@ export function extractTemplateVariables( } // curly, jinja2, and mustache all use {{variableName}} for variable substitution - // Linear scan: find '{{', then find '}}', extract the content between them + // Linear scan: find '{{', then find '}}', extract the content between them. + // + // For mustache, skip block markers — they're structural syntax, not + // variables, and shouldn't surface as input ports: + // - `{{#name}}` / `{{^name}}` — section opens / inverted sections + // - `{{/name}}` — section closes + // - `{{!comment}}` — comments + // - `{{.}}` — the implicit iterator (current value inside a section) + // - `{{> partial}}` — partials (rejected at render time, but skip here too) + // The mustache renderer pairs these structurally; the FE extractor must + // not treat them as referenced variables. The TokenPlugin highlights + // them via its own regex — this filter is for port discovery only. let i = 0 while (i < input.length - 1) { if (input[i] === "{" && input[i + 1] === "{") { @@ -514,7 +525,8 @@ export function extractTemplateVariables( const end = input.indexOf("}}", start) if (end !== -1) { const variable = input.slice(start, end).trim() - if (variable && !variables.includes(variable)) { + const isMustacheBlockMarker = variable === "." || /^[#/^!>]/.test(variable) + if (variable && !isMustacheBlockMarker && !variables.includes(variable)) { variables.push(variable) } i = end + 2 diff --git a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts new file mode 100644 index 0000000000..a7155ede04 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts @@ -0,0 +1,95 @@ +/** + * Unit tests for extractTemplateVariables. + * + * Pins: mustache block markers (`{{#name}}`, `{{/name}}`, `{{^name}}`, + * `{{!comment}}`, `{{> partial}}`, `{{.}}`) are NOT extracted as variables. + * They are structural mustache syntax; treating them as variables produces + * phantom input ports for "languages" / "comment" / etc. + * + * Plain variables, dotted access, and JSONPath ARE extracted. + */ +import {describe, expect, it} from "vitest" + +import {extractTemplateVariables} from "../../src/runnable/utils" + +describe("extractTemplateVariables", () => { + describe("mustache", () => { + it("extracts plain variables", () => { + expect(extractTemplateVariables("Hello {{name}}", "mustache")).toEqual(["name"]) + }) + + it("extracts dotted-name access", () => { + expect(extractTemplateVariables("Region: {{geo.region}}", "mustache")).toEqual([ + "geo.region", + ]) + }) + + it("extracts JSONPath expressions verbatim", () => { + expect(extractTemplateVariables("{{$.geo.region}}", "mustache")).toEqual([ + "$.geo.region", + ]) + }) + + it("skips section open `{{#name}}`", () => { + expect( + extractTemplateVariables("{{#languages}}{{.}}{{/languages}}", "mustache"), + ).toEqual([]) + }) + + it("skips inverted section open `{{^name}}`", () => { + expect(extractTemplateVariables("{{^empty}}none{{/empty}}", "mustache")).toEqual([]) + }) + + it("skips section close `{{/name}}`", () => { + expect(extractTemplateVariables("hello {{/languages}} world", "mustache")).toEqual([]) + }) + + it("skips comments `{{! ... }}`", () => { + expect(extractTemplateVariables("hello {{! a comment }} world", "mustache")).toEqual([]) + }) + + it("skips partials `{{> name}}`", () => { + // Partials are unsupported at runtime, but the extractor must + // not surface them as variables either. + expect(extractTemplateVariables("hello {{> partial}} world", "mustache")).toEqual([]) + }) + + it("skips the implicit iterator `{{.}}`", () => { + expect(extractTemplateVariables("{{.}}", "mustache")).toEqual([]) + }) + + it("extracts variables alongside block markers (filters only markers)", () => { + const out = extractTemplateVariables( + "Hi {{name}}, list: {{#items}}- {{.}}{{/items}}. End.", + "mustache", + ) + expect(out).toEqual(["name"]) + }) + + it("deduplicates repeated variables", () => { + expect(extractTemplateVariables("{{a}} {{a}} {{b}}", "mustache")).toEqual(["a", "b"]) + }) + }) + + describe("curly (legacy)", () => { + it("extracts {{name}} variables (same code path as mustache)", () => { + expect(extractTemplateVariables("Hello {{name}}", "curly")).toEqual(["name"]) + }) + + it("filters out section-like prefixes too (defensive — curly doesn't use them)", () => { + // Curly doesn't have sections, but if a user pastes mustache + // syntax into a curly prompt, we don't want phantom ports. + expect(extractTemplateVariables("{{#items}}{{/items}}", "curly")).toEqual([]) + }) + }) + + describe("fstring", () => { + it("extracts {variable} with single braces", () => { + expect(extractTemplateVariables("Hello {name}", "fstring")).toEqual(["name"]) + }) + + it("treats {{ as escaped literal, not a variable", () => { + expect(extractTemplateVariables("Hello {{name}}", "fstring")).toEqual([]) + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/port-helpers.test.ts b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts index a08dda6802..4369e8fa45 100644 --- a/web/packages/agenta-entities/tests/unit/port-helpers.test.ts +++ b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts @@ -174,10 +174,25 @@ describe("groupTemplateVariables", () => { expect(result).toHaveLength(1) }) - it("ignores invalid template variables", () => { - // '$.invalid.x' is not a known envelope slot, so it should be skipped - const result = groupTemplateVariables(["$.invalid.x"]) - expect(result).toHaveLength(0) + it("ignores invalid template variables (envelope-slot typos)", () => { + // The validator flags envelope-slot typos (e.g. `input` → `inputs`) + // as invalid; groupTemplateVariables skips those. Non-typo roots + // (e.g. `$.geo.region`) are NOW accepted as testcase-spread keys + // per the RFC ("testcase top-level keys are spread into the render + // context") — they DO produce a group rooted at `inputs`. + const typo = groupTemplateVariables(["$.input.x"]) + expect(typo).toHaveLength(0) + }) + + it("treats non-envelope JSONPath roots as testcase-spread inputs", () => { + // RFC canonical: `{{$.profile.name}}` against a `profile` testcase + // column. parseTemplateExpression routes the non-envelope first + // segment under the `inputs` envelope, key = first segment. + const result = groupTemplateVariables(["$.geo.region"]) + expect(result).toHaveLength(1) + expect(result[0].envelope).toBe("inputs") + expect(result[0].key).toBe("geo") + expect(result[0].subPaths).toContain("region") }) }) From ac0c65c0fc5d9fe355398a6ec1df21680bf6375f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 14:37:52 +0200 Subject: [PATCH 15/41] fix(frontend): visibility atom dereferences entity.data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playgroundInputsAtomFamily was treating the testcaseMolecule entity as if it were the row data dict. `testcaseMolecule.data(id)` returns `{id, data: {...row...}, flags, tags, meta}` — the row columns live at `entity.data`, not at the entity's top level. Symptom: after loading a testset, every referenced variable rendered with the `draft` badge ("Not on testcase yet"), and the unreferenced- columns footer counted the entity's own fields (`flags`, `tags`, ...) instead of testcase columns. Native data was on the molecule but my atom couldn't see it because `"country" in entity` was always false. Fix: switch to `testcaseMolecule.data(testcaseId)` (the canonical accessor pattern used by `testcaseDataAtomFamily`) and access `entity.data` for the row columns. --- .../src/state/execution/selectors.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-playground/src/state/execution/selectors.ts b/web/packages/agenta-playground/src/state/execution/selectors.ts index ad0606b060..5bb60984c6 100644 --- a/web/packages/agenta-playground/src/state/execution/selectors.ts +++ b/web/packages/agenta-playground/src/state/execution/selectors.ts @@ -416,9 +416,14 @@ export const playgroundInputsAtomFamily = atomFamily( ({testcaseId, downstreamKey = ""}: PlaygroundInputsAtomKey) => atom((get) => { const referencedKeys = get(referencedVariableKeysAtomFamily(downstreamKey)) - const raw = - (get(testcaseMolecule.atoms.data(testcaseId)) as Record | null) ?? - {} + // `testcaseMolecule.data(id)` returns the testcase ENTITY + // (`{id, data, flags, tags, meta, ...}`), not the row data + // dict. The actual column values live at `entity.data` — see + // `testcaseDataAtomFamily` below for the canonical pattern. + const entity = get(testcaseMolecule.data(testcaseId)) as { + data?: Record + } | null + const raw = entity?.data ?? {} // Strip system fields up-front so the unused-columns footer // doesn't expose `__id__` and friends to the user. From 2b20b9544b490bd5df3833a8da3ff1b9089f92e4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 15:02:11 +0200 Subject: [PATCH 16/41] fix(frontend): native JSON survives the completion+chat request body path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 only killed `normalizeCompact` in the evaluator path. The completion + chat playground request body went through TWO MORE stringification points in executionItems.ts: 1. `resolveVariableValues` returned `Record` via `stringifyValue`, which `JSON.stringify`'d every object/array. That dict gets merged into `data.inputs` by `transformToRequestBody` — so the backend received `geo: '{"region":"..."}'` (string) instead of `geo: {region: "..."}` (object). 2. `buildCompletionInputRow` wrapped each value as `{value: String(v)}`, turning `{region: "..."}` into `"[object Object]"` for completion prompts. Symptom user hit: `{{$.geo.region}}` raised "Unreplaced variables in mustache template" because the JSONPath resolver can't navigate into a string. mystache's `{{geo.region}}` silently returned empty (mystache is permissive on missing keys) so plain dotted access LOOKED like it worked while actually rendering blank. Fix: - `resolveVariableValues` now returns `Record`, passes values through native. - `buildCompletionInputRow` drops the `String(value)` wrap; the request-body's `extractInputValues` already handles native object values without coercion. - `variableValues` type signatures widened to `Record` in both ResolveVariableValues callers and BuildRequestBody params. `transformToRequestBody` already typed `variableValues?: Record` so no API change there — this just fixes the producers. After this, `{{geo.region}}` and `{{$.geo.region}}` both resolve to the same value at the backend. --- .../src/state/execution/executionItems.ts | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/web/packages/agenta-playground/src/state/execution/executionItems.ts b/web/packages/agenta-playground/src/state/execution/executionItems.ts index d1a455368d..ae30f11cc3 100644 --- a/web/packages/agenta-playground/src/state/execution/executionItems.ts +++ b/web/packages/agenta-playground/src/state/execution/executionItems.ts @@ -166,7 +166,7 @@ interface BuildExecutionItemBaseParams { requestPayload?: RequestPayloadData | null invocationUrl?: string | null variables?: string[] - variableValues?: Record + variableValues?: Record agConfigFallbacks?: AgConfigFallbackCandidate[] /** Runtime-resolved inputs (e.g. from chain upstream). Merged into rawBody.inputs when __rawBody is true. */ inputValues?: Record @@ -352,34 +352,32 @@ function resolveVariableRowId(params: ResolveVariableRowIdParams): string | null return displayRowIds[0] ?? null } -function stringifyValue(value: unknown): string { - if (value === undefined || value === null) return "" - if (typeof value === "string") return value - if (typeof value === "object") { - try { - return JSON.stringify(value) - } catch { - return String(value) - } - } - return String(value) -} - -function resolveVariableValues(params: ResolveVariableValuesParams): Record { +/** + * Resolve testcase values to a dict for transport. Values are passed + * through NATIVE (object stays object, array stays array, number stays + * number) so the backend mustache / JSONPath resolver can navigate them + * (RFC: "native JSON stays native until template rendering"). + * + * Prior implementation stringified via JSON.stringify for objects/arrays, + * which made `{{$.geo.region}}` fail at the backend with + * "Unreplaced variables in mustache template" because the JSONPath + * resolver can't navigate into a string. + */ +function resolveVariableValues(params: ResolveVariableValuesParams): Record { const {allowedVariableKeys, sourceRowData} = params const source = sourceRowData ?? {} if (Array.isArray(allowedVariableKeys) && allowedVariableKeys.length > 0) { - const values: Record = {} + const values: Record = {} for (const key of allowedVariableKeys) { - values[key] = stringifyValue(source[key]) + values[key] = source[key] } return values } - const values: Record = {} + const values: Record = {} for (const [key, value] of Object.entries(source)) { - values[key] = stringifyValue(value) + values[key] = value } return values } @@ -393,9 +391,14 @@ function buildCompletionInputRow( const keys = allowedVariableKeys.length > 0 ? allowedVariableKeys : Object.keys(sourceRowData) const enhanced: Record = {__id: rowId} + // Pass values through NATIVE (RFC: "native JSON stays native until + // template rendering"). `extractInputValues` at the request-body layer + // already handles native object/array values without coercion. Prior + // `String(value)` wrap turned `{region: "..."}` into `"[object Object]"` + // which made even mustache `{{geo.region}}` resolve to empty. for (const key of keys) { const value = sourceRowData[key] - enhanced[key] = {value: value !== undefined && value !== null ? String(value) : ""} + enhanced[key] = {value: value === undefined || value === null ? "" : value} } return enhanced @@ -986,7 +989,7 @@ function buildRequestBody( chatHistory?: TransformMessage[] requestPayload: RequestPayloadData | null | undefined variables: string[] - variableValues: Record + variableValues: Record entityId: string agConfigFallbacks?: AgConfigFallbackCandidate[] }, From 699cdd398ae47e9dfb853110e64e830fa88e8e9a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 17:06:21 +0200 Subject: [PATCH 17/41] feat(frontend): wire PlaygroundInputsBody into ComparisonLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror SingleLayout's feature-flagged swap. When `useNewPlaygroundInputsBodyAtom` is on, the comparison view renders a single shared `PlaygroundInputsBodyHost` (V2 bordered cards + type chips + "View as ▾" dropdown) instead of the per-variable `VariableControlAdapter` loop. Row-level controls (open focus drawer + delete row) move out of each variable's header cluster into a small toolbar above the shared inputs body — one cluster instead of N — since the new cards have their own header design and the actions are testcase-row scoped, not per-variable. The existing per-variable loop stays intact behind the flag for any consumer not opted in; the public Props surface is unchanged. Closes the second of the deferred follow-ups from the mustache-support design (ComparisonLayout swap). The feature flag can be removed once the grouped evaluator layout swap is also done. --- .../assets/ExecutionRow/ComparisonLayout.tsx | 195 ++++++++++++------ 1 file changed, 129 insertions(+), 66 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx index 5695931cab..a879042df9 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx @@ -16,7 +16,11 @@ import clsx from "clsx" import {atom, useAtomValue, useSetAtom} from "jotai" import {VariableControlAdapter} from "@agenta/playground-ui/adapters" -import {openPlaygroundFocusDrawerAtom} from "@agenta/playground-ui/state" +import {PlaygroundInputsBodyHost} from "@agenta/playground-ui/playground-inputs-body" +import { + openPlaygroundFocusDrawerAtom, + useNewPlaygroundInputsBodyAtom, +} from "@agenta/playground-ui/state" import {usePlaygroundUIOptional} from "../../../../context/PlaygroundUIContext" @@ -100,6 +104,24 @@ const ComparisonLayout = ({ const structuralRootNode = rootNodes[0] ?? null const hasDownstreamNodes = downstreamNodes.length > 0 + // Downstream key — same shape as SingleLayout. Used by + // `PlaygroundInputsBodyHost`'s visibility selector to namespace the + // referenced-vs-unreferenced split per evaluator chain. + const downstreamKey = useMemo( + () => + downstreamNodes + .map((n) => n.entityId) + .sort() + .join(","), + [downstreamNodes], + ) + + // Feature flag — when true, the comparison view renders a single + // shared `PlaygroundInputsBodyHost` (V2 bordered cards + type chips + + // "View as ▾" dropdown) instead of the per-variable + // `VariableControlAdapter` loop. Off by default; OSS opts in. + const useNewInputsBody = useAtomValue(useNewPlaygroundInputsBodyAtom) + const {getNodeLabel} = usePlaygroundNodeLabels(nodes) const mapStatuses = useCallback( @@ -240,80 +262,121 @@ const ComparisonLayout = ({ >
- {variableIds.map((variableId, index) => ( -
- + {/* Row-level controls — moved out of the + * per-variable header cluster in the new + * inputs body. Open focus drawer + delete + * row live together in a single toolbar + * above the shared inputs body. */} + {!inputOnly && ( +
+ } + onClick={() => + openFocusDrawer({ + rowId, + entityId: + structuralRootNode?.entityId ?? entityId, + }) + } + disabled={!(structuralRootNode?.entityId ?? entityId)} + tooltipProps={{title: "Open details"}} + /> + } + onClick={() => deleteRow(rowId)} + disabled={rowCount <= 1} + tooltipProps={{title: "Remove"}} + /> +
+ )} + + + ) : ( + variableIds.map((variableId, index) => ( +
- - {index === 0 ? ( + > + + + {index === 0 ? ( + + } + onClick={() => + openFocusDrawer({ + rowId, + entityId: + structuralRootNode?.entityId ?? + entityId, + }) + } + disabled={ + !( + structuralRootNode?.entityId ?? + entityId + ) + } + tooltipProps={{title: "Open details"}} + /> + ) : null} - } - onClick={() => - openFocusDrawer({ - rowId, - entityId: - structuralRootNode?.entityId ?? - entityId, - }) - } - disabled={ - !( - structuralRootNode?.entityId ?? - entityId - ) - } - tooltipProps={{title: "Open details"}} + icon={} + onClick={() => deleteRow(rowId)} + disabled={rowCount <= 1} + tooltipProps={{title: "Remove"}} /> - ) : null} - } - onClick={() => deleteRow(rowId)} - disabled={rowCount <= 1} - tooltipProps={{title: "Remove"}} - /> - - ) : undefined - } - /> -
- ))} + + ) : undefined + } + /> +
+ )) + )}
From d9424b7e5bc9e3c6deb900265365c708c4fc515a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 26 May 2026 17:20:32 +0200 Subject: [PATCH 18/41] feat(frontend): grouped evaluator layout uses PlaygroundInputsBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the `useGroupedLayout === true` branch in SingleLayout off the per-variable `VariableControlAdapter` loop and onto `PlaygroundInputsBodyHost`. The host now takes an optional `sections` prop that partitions referenced variables into named left-border blocks, mirroring the legacy `` accent — `inputs` envelope + the extracted field ports share one block, `outputs` envelope sits in its own block. `VariableCard` gains optional `helpText`, surfaced as a small Info tooltip next to the variable name. The host enriches each visibility input with `helpText` from the input-port schema map, so evaluator envelope variables (`inputs`/`outputs`) keep the guidance tooltip the legacy `VariableHeader` used to render. The grouped + flat layouts now both flow through one component; SingleLayout's legacy SectionBlock + per-variable path is kept behind the feature flag for any consumer not opted in. Closes the grouped-evaluator deferred follow-up from the mustache-support design. --- .../assets/ExecutionRow/SingleLayout.tsx | 79 +++++++++++------- .../PlaygroundInputsBody.tsx | 83 +++++++++++++++---- .../PlaygroundInputsBodyHost.tsx | 68 +++++++++++++-- .../PlaygroundInputsBody/VariableCard.tsx | 22 ++++- 4 files changed, 202 insertions(+), 50 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx index 643e69810c..a56feedda6 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx @@ -874,44 +874,67 @@ const SingleView = ({ ) } - // Flat layout — apps, and evaluators with no - // extracted field ports (default template). - if (!useGroupedLayout) { - // New playground inputs body — V2-aligned - // bordered cards with type chips + "View - // as ▾" dropdowns. Behind a feature flag - // so the existing per-variable layout - // stays default until the new UX is - // signed off. See approved design doc, - // Step 6 deferred follow-ups for - // ComparisonLayout + picker placement. - if (useNewInputsBody) { + // New playground inputs body — V2-aligned + // bordered cards with type chips + "View + // as ▾" dropdowns. Behind a feature flag + // so the existing per-variable layout stays + // default until the new UX is signed off. + if (useNewInputsBody) { + // Grouped layout — evaluators with + // extracted field ports. Field ports + + // the `inputs` envelope catch-all share + // `data.inputs` at runtime; the new + // inputs body renders both inside the + // same left-border "inputs" section, + // and the `outputs` envelope in its + // own section. The section blocks come + // through the host's `sections` prop. + if (useGroupedLayout) { + const groupedSections = [ + { + ariaLabel: "inputs", + variableNames: [ + ...fieldPortIds, + ...(hasInputsEnvelope ? ["inputs"] : []), + ], + }, + ...(hasOutputsEnvelope + ? [ + { + ariaLabel: "outputs", + variableNames: ["outputs"], + }, + ] + : []), + ] return ( ) } - return variableIds.map((id) => renderVariable(id)) + return ( + + ) } - // Grouped layout — evaluators with field - // ports. Field ports + the envelope catch-all - // share `data.inputs` at runtime, so we wrap - // them in one left-border block to surface - // that relationship visually. Each port - // renders with its full header (label + ⓘ - // tooltip + hover-revealed action cluster: - // JSON/text toggle, markdown, copy, collapse) - // and the editor's own border, so envelope - // and field rows look identical and the - // hover affordances stay intact. The group - // identity comes from the container; we - // intentionally don't add a separate header - // label to avoid colliding with the envelope - // port's own header. + // Legacy path (flag off) — flat layout for + // apps and evaluators-with-no-fields, grouped + // legacy SectionBlock layout for evaluators + // with extracted field ports. Each port renders + // through the per-variable + // `VariableControlAdapter` with its full header + // (label + ⓘ tooltip + hover-revealed actions). + if (!useGroupedLayout) { + return variableIds.map((id) => renderVariable(id)) + } return ( <> diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx index 0781b5d54f..1cbdb3fa7e 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx @@ -43,6 +43,26 @@ export interface PlaygroundInputsBodyVariable { /** True when the variable is referenced by the prompt but not authored * on the testcase yet. Renders a `[draft]` badge. */ isDraft?: boolean + /** Optional tooltip text explaining the variable's role. Surfaced as a + * small Info icon in the card header — used for evaluator envelope + * variables (`inputs`/`outputs`) to keep the legacy guidance visible. */ + helpText?: string +} + +/** + * Optional section grouping for the variable cards. When present (see + * `PlaygroundInputsBodyProps.sections` below), each section renders inside + * a left-border accent block — mirrors the legacy `` look the + * grouped evaluator layout used in `SingleLayout`. Variable cards inside a + * section behave exactly like ungrouped cards otherwise. + */ +export interface PlaygroundInputsBodySection { + /** Aria label for the group (e.g. `"inputs"` / `"outputs"`). Not + * rendered as a visible heading — the left-border + the per-card + * TypeChip + name carry the disambiguation. */ + ariaLabel: string + /** Variables rendered inside this section, in order. */ + variables: PlaygroundInputsBodyVariable[] } export interface PlaygroundInputsBodyProps { @@ -53,8 +73,13 @@ export interface PlaygroundInputsBodyProps { rowId: string /** Variables referenced by the prompt chain. Rendered as expanded cards * in order. Include draft variables (referenced but not on testcase) - * with `isDraft: true`. */ + * with `isDraft: true`. Ignored when `sections` is provided. */ inputs: PlaygroundInputsBodyVariable[] + /** Optional grouped layout. When present, replaces the flat `inputs` + * rendering with one left-border block per section. Used by the + * evaluator grouped layout (`inputs` envelope + extracted field ports + * in one block, `outputs` envelope in another). */ + sections?: PlaygroundInputsBodySection[] /** Testcase columns NOT referenced by the prompt chain. Rendered under * a single collapsed footer below all variable cards. Pass `undefined` * or `[]` to skip the footer entirely. */ @@ -80,6 +105,7 @@ export interface PlaygroundInputsBodyProps { export function PlaygroundInputsBody({ rowId, inputs, + sections, unreferencedColumns, editable, onValueChange, @@ -87,8 +113,15 @@ export function PlaygroundInputsBody({ onViewModeChange, unreferencedEditable = false, }: PlaygroundInputsBodyProps) { + // `sections` takes precedence over the flat `inputs` list. We still need + // to look up by name to route draft edits, so unify the membership + // source here. + const allVariables: PlaygroundInputsBodyVariable[] = sections + ? sections.flatMap((s) => s.variables) + : inputs + const handleValueChange = (name: string, value: unknown) => { - const variable = inputs.find((v) => v.name === name) + const variable = allVariables.find((v) => v.name === name) if (variable?.isDraft && onAddDraftColumn) { onAddDraftColumn(name, value) } else { @@ -96,22 +129,40 @@ export function PlaygroundInputsBody({ } } + const renderCard = (variable: PlaygroundInputsBodyVariable) => ( + + ) + return (
- {inputs.map((variable) => ( - - ))} + {sections + ? sections.map((section) => ( +
` accent in + // `SingleLayout`. No visible heading — the chip + name + // on each card carries the per-variable label, and the + // left-border conveys the group identity. + className="flex flex-col gap-2 pl-3 border-0 border-l-2 border-solid border-[#1677FF22]" + > + {section.variables.map(renderCard)} +
+ )) + : inputs.map(renderCard)} {unreferencedColumns && unreferencedColumns.length > 0 ? ( + + const enrichedInputs = useMemo( + () => + visibility.inputs.map((v) => { + const help = portSchemaMap[v.name]?.helpText + return help ? {...v, helpText: help} : v + }), + [visibility.inputs, portSchemaMap], + ) + + // Partition enriched inputs into sections when `sections` is provided. + // Variables not listed in any section are appended as ungrouped at the + // end (caller responsibility to list every key for a clean layout). + const bodySections = useMemo(() => { + if (!sections) return undefined + const byName = new Map(enrichedInputs.map((v) => [v.name, v])) + const claimed = new Set() + const groups: PlaygroundInputsBodySection[] = sections.map((spec) => { + const variables: PlaygroundInputsBodyVariable[] = [] + for (const name of spec.variableNames) { + const v = byName.get(name) + if (!v) continue + variables.push(v) + claimed.add(name) + } + return {ariaLabel: spec.ariaLabel, variables} + }) + const leftover = enrichedInputs.filter((v) => !claimed.has(v.name)) + if (leftover.length > 0) { + groups.push({ariaLabel: "other", variables: leftover}) + } + return groups + }, [sections, enrichedInputs]) + const setCellValue = useSetAtom(executionItemController.actions.setTestcaseCellValue) const handleValueChange = useCallback( @@ -63,7 +120,8 @@ export function PlaygroundInputsBodyHost({ return ( + {helpText ? ( + + + + ) : null} {isDraft ? ( Date: Tue, 26 May 2026 17:50:47 +0200 Subject: [PATCH 19/41] feat(frontend): surface TemplateFormatPicker in playground section header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the prompt template_format picker to the PlaygroundConfigSection prompt section header, alongside the existing Refine + Configure-model controls. The picker lives where the user already looks for prompt- level settings — top right of the section — instead of being buried in the action bar at the bottom of the messages list. Wires through `updatePromptRootField("template_format", next)`, which already handles both canonical (root-level `parameters.template_format`) and legacy (`parameters.prompt.template_format`) shapes. Reads from `promptModelInfo.promptValue.template_format` / `templateFormat` so both naming variants are respected. Note: `PromptSchemaControl` still renders its own inline Select in the action bar (kept from #4393's BE+SDK work) so any consumer using PromptSchemaControl directly outside PlaygroundConfigSection keeps a picker. In the playground, both pickers stay in sync via the existing value→localTemplateFormat sync effect. Removing the action-bar picker inside PlaygroundConfigSection is a follow-up consolidation if the header placement is approved. Closes the picker-placement deferred follow-up from the mustache-support design. --- .../components/PlaygroundConfigSection.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index 48b800baf2..4add6677aa 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -40,6 +40,7 @@ import {atom} from "jotai" import {useAtom, useAtomValue, useSetAtom} from "jotai" import yaml from "js-yaml" +import {TemplateFormatPicker, type TemplateFormat} from "../../template-format" import {getModelSchema, getLLMConfigValue, getLLMConfigProperties} from "../SchemaControls" import {feedbackConfigModeAtomFamily} from "../SchemaControls/FeedbackConfigurationControl" import { @@ -1381,6 +1382,26 @@ function PlaygroundConfigSection({ onClick={(e) => e.stopPropagation()} className="flex items-center gap-2 flex-shrink-0" > + {/* Prompt template_format picker — let the user + * switch between Mustache (default), Jinja2, and + * legacy formats (Curly / F-string offered only + * when the prompt is already on one). Writes + * flow through `updatePromptRootField` so the + * value lives at `prompt.template_format` for + * legacy shapes and at the root for canonical. */} + updatePromptRootField("template_format", next)} + /> {!disabled && onRefinePrompt && hasMessages && ( - + options={selectOptions} + popupMatchSelectWidth={false} + className={className} + style={{minWidth: 90}} + /> ) } -const styles = { - trigger: { - display: "inline-flex", - alignItems: "center", - gap: 4, - padding: "0 8px", - height: 24, - borderRadius: 4, - fontSize: 12, - color: "#051729", - }, - triggerLabel: {color: "rgba(5, 23, 41, 0.55)"}, - triggerValue: {color: "#051729", fontWeight: 600}, - triggerCaret: {marginTop: 1, opacity: 0.65}, - leadLabel: { - fontSize: 11, - fontWeight: 600, - color: "rgba(5, 23, 41, 0.55)", - textTransform: "uppercase" as const, - letterSpacing: "0.04em", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", - }, - optionRow: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 16, - minWidth: 200, - }, - optionLabel: {fontSize: 13, fontWeight: 500, color: "#051729"}, - optionHint: {fontSize: 11, color: "rgba(5, 23, 41, 0.55)"}, -} - export default ViewTypeSelect From d8db84db1e77db68460e06f216234932bcc6addb Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 11:31:03 +0200 Subject: [PATCH 24/41] feat(frontend): infer `array` type for mustache section-opener-only ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name referenced ONLY as a mustache section opener — like `{{#languages}}{{.}}{{/languages}}` — was previously bucketed as a plain `string` port. The iteration intent (`#name` says "iterate over this if it's an array") is the strongest signal we have about its shape; the new behaviour surfaces it as an `array` port with the matching TypeChip and view-mode defaults. How: new helper extractMustacheSectionOpeners(input, fmt) → Set of names that appeared as `{{#name}}` / `{{^name}}` in the source template. Empty for non- mustache formats. new helper extractSectionOpenersFromConfig(agConfig) → mirrors extractVariablesFromConfig — collects section openers across every prompt-like entry's messages. groupTemplateVariables now accepts an optional `{sectionOpeners?: Set}` hint. Type inference priority: 1. Sub-paths present → "object" (strongest signal) 2. In sectionOpeners AND no sub-paths → "array" (iteration intent) 3. Otherwise → "string" workflow molecule callers (`buildEvaluatorFieldPortsFromTemplate` + the two non-evaluator branches in `inputPortsAtomFamily`) compute the opener set per content/config and pass it through. Array ports emit `{schema: {type: "array"}}` so the empty-shape seed produces `[]` and the new playground inputs body can offer a sensible JSON skeleton on drafts. Defaults adjusted: array drafts default to JSON view (not Form). FormView has no add-item affordance for an empty `[]`, so JSON's editable buffer is the more useful entry point. Form stays in the dropdown — it works well once items exist. Tests added: section-opener extraction (mustache only), grouping with the hint (priority: object > array > string), array default view, the sub-pathed-AND-section case (object wins). --- .../agenta-entities/src/runnable/index.ts | 2 + .../src/runnable/portHelpers.ts | 35 +++++-- .../agenta-entities/src/runnable/utils.ts | 95 +++++++++++++++++++ .../src/workflow/state/molecule.ts | 71 ++++++++++---- .../unit/extract-template-variables.test.ts | 46 ++++++++- .../tests/unit/port-helpers.test.ts | 41 ++++++++ 6 files changed, 265 insertions(+), 25 deletions(-) diff --git a/web/packages/agenta-entities/src/runnable/index.ts b/web/packages/agenta-entities/src/runnable/index.ts index dc3ddcf0f5..9e40114b0a 100644 --- a/web/packages/agenta-entities/src/runnable/index.ts +++ b/web/packages/agenta-entities/src/runnable/index.ts @@ -128,10 +128,12 @@ export { buildEvaluatorExecutionInputs, validateEvaluatorInputs, // Template variable extraction + extractMustacheSectionOpeners, extractTemplateVariables, extractTemplateVariablesFromJson, extractVariablesFromPrompts, extractVariablesFromConfig, + extractSectionOpenersFromConfig, extractVariablesFromEnhancedPrompts, } from "./utils" export type {ExecuteRunnableOptions} from "./utils" diff --git a/web/packages/agenta-entities/src/runnable/portHelpers.ts b/web/packages/agenta-entities/src/runnable/portHelpers.ts index b969febb8a..46b2a85f32 100644 --- a/web/packages/agenta-entities/src/runnable/portHelpers.ts +++ b/web/packages/agenta-entities/src/runnable/portHelpers.ts @@ -142,11 +142,14 @@ export interface GroupedTemplateVariable { /** Display label. Same as `key` under this model. */ name: string /** - * Declared shape. `"object"` when any placeholder references a sub-path - * of this group (signals the UI to render a JSON editor); otherwise - * `"string"`. + * Declared shape. + * - `"object"` when any placeholder references a sub-path of this + * group (`{{geo.region}}` → object with `region` sub-path). + * - `"array"` when the name appears ONLY as a mustache section opener + * (`{{#languages}}{{.}}{{/languages}}` → array, no sub-paths). + * - `"string"` otherwise. */ - type: "string" | "object" + type: "string" | "object" | "array" /** * Known sub-paths beneath the group (populated only when `type === "object"`). * Used to seed a shape-hint default in the JSON editor so users see @@ -285,8 +288,20 @@ function parseTemplateExpression(expr: string): ParsedTemplateExpression { * `envelope === "inputs"`; other slots are runtime-resolved (backend * populates them from trace / workflow config / etc.). */ -export function groupTemplateVariables(placeholders: string[]): GroupedTemplateVariable[] { +export function groupTemplateVariables( + placeholders: string[], + options?: { + /** Set of names that appeared as mustache section openers + * (`{{#name}}` / `{{^name}}`) in the source template. Used to + * refine type inference: a name referenced ONLY as a section + * opener (no sub-paths) gets `type: "array"` — the iteration + * intent is the strongest signal we have without parsing the + * block body. Names with sub-paths stay `"object"` regardless. */ + sectionOpeners?: Set + }, +): GroupedTemplateVariable[] { const groups = new Map}>() + const sectionOpeners = options?.sectionOpeners for (const placeholder of placeholders) { // Invalid envelope references (e.g. `$.input.xx.abc` — `input` is not @@ -313,11 +328,19 @@ export function groupTemplateVariables(placeholders: string[]): GroupedTemplateV return Array.from(groups.values()).map(({envelope, key, subPaths}) => { const subPathList = Array.from(subPaths) + // Type inference priority: + // 1. Sub-paths present → `"object"` (the strongest signal — the + // template addresses specific fields). + // 2. Section opener AND no sub-paths → `"array"` (iteration intent). + // 3. Otherwise → `"string"`. + const isSectionOpener = sectionOpeners?.has(key) ?? false + const type: GroupedTemplateVariable["type"] = + subPathList.length > 0 ? "object" : isSectionOpener ? "array" : "string" return { envelope, key, name: key, - type: subPathList.length > 0 ? ("object" as const) : ("string" as const), + type, ...(subPathList.length > 0 ? {subPaths: subPathList} : {}), } }) diff --git a/web/packages/agenta-entities/src/runnable/utils.ts b/web/packages/agenta-entities/src/runnable/utils.ts index 35120f7f09..f989ba8f14 100644 --- a/web/packages/agenta-entities/src/runnable/utils.ts +++ b/web/packages/agenta-entities/src/runnable/utils.ts @@ -555,6 +555,47 @@ export function extractTemplateVariables( return variables } +/** + * Extract names that appear as mustache SECTION OPENERS (`{{#name}}` or + * `{{^name}}`) from a template string. + * + * Distinct from `extractTemplateVariables`, which returns every referenced + * placeholder (section openers, plain variables, dotted access). Callers use + * this set as a hint when grouping variables — a name referenced ONLY as a + * section opener with no sub-paths is best surfaced as an `array` port + * (iteration intent) rather than the default `string` port. + * + * Always returns an empty set for non-mustache formats: curly / jinja2 / + * fstring don't have section semantics, so the hint isn't meaningful there. + */ +export function extractMustacheSectionOpeners( + input: string, + templateFormat: TemplateFormat = "curly", +): Set { + const names = new Set() + if (templateFormat !== "mustache") return names + + let i = 0 + while (i < input.length - 1) { + if (input[i] === "{" && input[i + 1] === "{") { + const start = i + 2 + const end = input.indexOf("}}", start) + if (end === -1) break + const raw = input.slice(start, end).trim() + // Section opener (`#name`) or inverted section opener (`^name`). + // `&name` is variable unescape, not a section — exclude. + if (/^[#^]/.test(raw)) { + const name = raw.slice(1).trim() + if (name) names.add(name) + } + i = end + 2 + } else { + i++ + } + } + return names +} + /** * Extract template variables from a JSON object recursively * @param obj - Object to extract variables from @@ -732,6 +773,60 @@ export function extractVariablesFromConfig( return variables } +/** + * Mirror of `extractVariablesFromConfig` that collects mustache SECTION + * OPENERS (`{{#name}}` / `{{^name}}`) across all prompt-like entries in + * config. Used alongside `extractVariablesFromConfig` to feed the + * `sectionOpeners` hint into `groupTemplateVariables`, so a name referenced + * only via section markers (no sub-paths) surfaces as an `array` port + * instead of the default `string` port. + * + * Always returns an empty set for non-mustache prompts — section semantics + * are mustache-specific. + */ +export function extractSectionOpenersFromConfig( + agConfig: Record | undefined, +): Set { + const openers = new Set() + if (!agConfig) return openers + + for (const value of Object.values(agConfig)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue + const prompt = value as Record + + const rawTf = (prompt.template_format ?? prompt.templateFormat) as string | undefined + const tf = resolveTemplateFormat(rawTf) ?? "curly" + if (tf !== "mustache") continue + + if (!Array.isArray(prompt.messages)) continue + for (const message of prompt.messages) { + if (!message || typeof message !== "object") continue + const content = (message as Record).content + if (typeof content === "string") { + for (const opener of extractMustacheSectionOpeners(content, tf)) { + openers.add(opener) + } + } else if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === "string") { + for (const opener of extractMustacheSectionOpeners(part, tf)) { + openers.add(opener) + } + } else if (part && typeof part === "object") { + const text = (part as Record).text + if (typeof text === "string") { + for (const opener of extractMustacheSectionOpeners(text, tf)) { + openers.add(opener) + } + } + } + } + } + } + } + return openers +} + /** * Synchronize `input_keys` for prompt configs in a parameters object. * diff --git a/web/packages/agenta-entities/src/workflow/state/molecule.ts b/web/packages/agenta-entities/src/workflow/state/molecule.ts index 55a56521af..f819fafedf 100644 --- a/web/packages/agenta-entities/src/workflow/state/molecule.ts +++ b/web/packages/agenta-entities/src/workflow/state/molecule.ts @@ -50,6 +50,8 @@ import { } from "../../runnable/portHelpers" import {normalizeWorkflowResponse} from "../../runnable/responseHelpers" import { + extractMustacheSectionOpeners, + extractSectionOpenersFromConfig, extractTemplateVariables, extractVariablesFromConfig, resolveTemplateFormat, @@ -771,15 +773,21 @@ function buildEvaluatorFieldPortsFromTemplate(entity: Workflow | null | undefine const fmt = resolveTemplateFormat(rawFmt) ?? "curly" const placeholders: string[] = [] + const sectionOpeners = new Set() for (const message of messages) { const content = message?.content if (typeof content !== "string") continue for (const v of extractTemplateVariables(content, fmt)) { if (!placeholders.includes(v)) placeholders.push(v) } + // Collect mustache `{{#name}}` / `{{^name}}` openers so iteration + // intent surfaces as `type: "array"` for ports with no sub-paths. + for (const opener of extractMustacheSectionOpeners(content, fmt)) { + sectionOpeners.add(opener) + } } - const groups = groupTemplateVariables(placeholders) + const groups = groupTemplateVariables(placeholders, {sectionOpeners}) const ports: RunnablePort[] = [] const seen = new Set() for (const group of groups) { @@ -792,23 +800,36 @@ function buildEvaluatorFieldPortsFromTemplate(entity: Workflow | null | undefine if (RESERVED_FIELD_KEYS.has(group.key)) continue if (seen.has(group.key)) continue seen.add(group.key) + const baseHelpText = `Field referenced in your prompt as \`{{$.inputs.${group.key}}}\`${ + group.type === "string" ? ` or \`{{${group.key}}}\`` : "" + }${ + group.type === "array" + ? ` (used as a mustache section: \`{{#${group.key}}}…{{/${group.key}}}\`)` + : "" + }. Note: this value is merged into the \`inputs\` envelope at runtime, so it also appears inside \`{{inputs}}\` if your prompt renders the whole envelope.` + + // Emit an explicit `array` schema for section-opener ports so the + // empty-shape seed produces `[]` (instead of nothing) and the new + // playground inputs body can show a sensible JSON skeleton on + // drafts. Object ports already get a schema from `subPaths`. + const schema = + group.subPaths && group.subPaths.length > 0 + ? { + type: "object", + properties: Object.fromEntries( + group.subPaths.map((sp) => [sp, {type: "string"}]), + ), + } + : group.type === "array" + ? {type: "array"} + : undefined + ports.push({ key: group.key, name: group.key, type: group.type, - helpText: `Field referenced in your prompt as \`{{$.inputs.${group.key}}}\`${ - group.type === "string" ? ` or \`{{${group.key}}}\`` : "" - }. Note: this value is merged into the \`inputs\` envelope at runtime, so it also appears inside \`{{inputs}}\` if your prompt renders the whole envelope.`, - ...(group.subPaths && group.subPaths.length > 0 - ? { - schema: { - type: "object", - properties: Object.fromEntries( - group.subPaths.map((sp) => [sp, {type: "string"}]), - ), - }, - } - : {}), + helpText: baseHelpText, + ...(schema ? {schema} : {}), }) } return ports @@ -883,14 +904,21 @@ const inputPortsAtomFamily = atomFamily((workflowId: string) => if (params) { const vars = extractVariablesFromConfig(params as Record) if (vars.length > 0) { - return groupTemplateVariables(vars) + const sectionOpeners = extractSectionOpenersFromConfig( + params as Record, + ) + return groupTemplateVariables(vars, {sectionOpeners}) .filter((group) => group.envelope === "inputs") .map((group) => ({ key: group.key, name: group.name, type: group.type, required: true, - ...(group.subPaths ? {schema: buildSubPathSchema(group.subPaths)} : {}), + ...(group.subPaths + ? {schema: buildSubPathSchema(group.subPaths)} + : group.type === "array" + ? {schema: {type: "array"}} + : {}), })) } } @@ -927,14 +955,21 @@ const inputPortsAtomFamily = atomFamily((workflowId: string) => (key) => !systemFields.has(key), ) if (vars.length > 0) { - return groupTemplateVariables(vars) + const sectionOpeners = extractSectionOpenersFromConfig( + params as Record, + ) + return groupTemplateVariables(vars, {sectionOpeners}) .filter((group) => group.envelope === "inputs") .map((group) => ({ key: group.key, name: group.name, type: group.type, required: true, - ...(group.subPaths ? {schema: buildSubPathSchema(group.subPaths)} : {}), + ...(group.subPaths + ? {schema: buildSubPathSchema(group.subPaths)} + : group.type === "array" + ? {schema: {type: "array"}} + : {}), })) } } diff --git a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts index 834c0f8cbf..44e4205203 100644 --- a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts +++ b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts @@ -19,7 +19,7 @@ */ import {describe, expect, it} from "vitest" -import {extractTemplateVariables} from "../../src/runnable/utils" +import {extractMustacheSectionOpeners, extractTemplateVariables} from "../../src/runnable/utils" describe("extractTemplateVariables", () => { describe("mustache", () => { @@ -123,3 +123,47 @@ describe("extractTemplateVariables", () => { }) }) }) + +describe("extractMustacheSectionOpeners", () => { + it("returns an empty set for non-mustache formats", () => { + // Section semantics are mustache-specific. Other formats don't get + // the hint even if `#name` appears in their templates. + expect(extractMustacheSectionOpeners("{{#name}}{{/name}}", "curly").size).toBe(0) + expect(extractMustacheSectionOpeners("{{#name}}{{/name}}", "jinja2").size).toBe(0) + expect(extractMustacheSectionOpeners("{#name}{/name}", "fstring").size).toBe(0) + }) + + it("picks up `{{#name}}` openers", () => { + const out = extractMustacheSectionOpeners( + "{{#languages}}{{.}}{{/languages}}", + "mustache", + ) + expect(Array.from(out)).toEqual(["languages"]) + }) + + it("picks up `{{^name}}` inverted-section openers", () => { + const out = extractMustacheSectionOpeners("{{^empty}}none{{/empty}}", "mustache") + expect(Array.from(out)).toEqual(["empty"]) + }) + + it("excludes `{{&name}}` (unescape is a variable, not a section)", () => { + expect(extractMustacheSectionOpeners("{{&html}}", "mustache").size).toBe(0) + }) + + it("excludes closers, comments, partials, the implicit iterator, and plain vars", () => { + expect( + extractMustacheSectionOpeners( + "{{/name}} {{!c}} {{> p}} {{.}} {{plain}}", + "mustache", + ).size, + ).toBe(0) + }) + + it("deduplicates repeated openers and mixes plain vars cleanly", () => { + const out = extractMustacheSectionOpeners( + "{{#items}}{{name}}{{/items}} and again {{#items}}{{/items}}", + "mustache", + ) + expect(Array.from(out)).toEqual(["items"]) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/port-helpers.test.ts b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts index 4369e8fa45..b2c342d2a9 100644 --- a/web/packages/agenta-entities/tests/unit/port-helpers.test.ts +++ b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts @@ -194,6 +194,47 @@ describe("groupTemplateVariables", () => { expect(result[0].key).toBe("geo") expect(result[0].subPaths).toContain("region") }) + + describe("sectionOpeners hint (mustache iteration intent)", () => { + it("marks a section-opener-only name as `array`", () => { + // `{{#languages}}{{.}}{{/languages}}` extracts `languages` as a + // plain variable; the sectionOpeners hint tells the grouper it's + // an iteration target → array type. + const result = groupTemplateVariables(["languages"], { + sectionOpeners: new Set(["languages"]), + }) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + envelope: "inputs", + key: "languages", + type: "array", + }) + }) + + it("keeps sub-pathed names as `object` even when in sectionOpeners", () => { + // `{{#user}}{{user.name}}{{/user}}` — `user` is in sectionOpeners + // BUT also has sub-paths, so it's an object (sub-paths are the + // stronger signal). + const result = groupTemplateVariables(["user", "$.user.name"], { + sectionOpeners: new Set(["user"]), + }) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("object") + expect(result[0].subPaths).toContain("name") + }) + + it("leaves non-opener names as `string`", () => { + const result = groupTemplateVariables(["plain"], { + sectionOpeners: new Set(["other"]), + }) + expect(result[0].type).toBe("string") + }) + + it("is opt-in — no hint behaves exactly as before", () => { + const result = groupTemplateVariables(["languages"]) + expect(result[0].type).toBe("string") + }) + }) }) // ── extractInputPortsFromSchema ─────────────────────────────────────────────── From ef6aef1505d3b8526bef7c1da8cb5063a2835868 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 11:31:52 +0200 Subject: [PATCH 25/41] feat(frontend): array drafts default to JSON view (not Form) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getViewOptionsForExpectedType` now special-cases `expectedType: "array"` to put JSON first in the dropdown. FormView has no "add item" affordance for an empty `[]` — the user would see "(empty object)" with no path forward. JSON's editable buffer is the natural entry point: the user types `["en", "fr"]` and they're done. Form is still in the options list (`[json default, form, yaml]`) so arrays with items can use it for per-index editing. Tests pin both cases: empty array defaults to JSON, real array (non-empty) defaults to Form (value-driven path takes over once a value exists). --- .../src/view-types/viewTypes.ts | 21 +++++++++++++++++-- .../tests/unit/view-types.test.ts | 21 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts index c7c806e947..b69f8f0818 100644 --- a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts +++ b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts @@ -172,6 +172,11 @@ function fieldKindFromExpected(expected: ExpectedType): FieldKind | null { * Same shape as `getViewOptions(value)`, but when `value` is empty, the * dropdown is built from `expectedType` instead. This is how a draft * variable known to be an object opens as `Form` rather than `Text`. + * + * Array drafts intentionally default to JSON: Form view has no good + * empty-array UX (no "add item" affordance for `[]`), so JSON's `[]` + * buffer is more useful. Form stays in the list for editing once items + * exist. */ export function getViewOptionsForExpectedType( value: unknown, @@ -185,13 +190,25 @@ export function getViewOptionsForExpectedType( if (expectedKind === "string") { opts.push({value: "text", label: "Text", hint: "default"}) opts.push({value: "markdown", label: "Markdown"}) + opts.push({value: "json", label: "JSON"}) + opts.push({value: "yaml", label: "YAML"}) } else if (expectedKind === "boolean") { opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "json", label: "JSON"}) + opts.push({value: "yaml", label: "YAML"}) + } else if (expectedType === "array") { + // JSON first for empty arrays — FormView can't surface an "add + // item" affordance for an empty `[]`, so JSON's editable buffer + // is the more useful entry point. Form stays available for once + // items exist. + opts.push({value: "json", label: "JSON", hint: "default"}) + opts.push({value: "form", label: "Form"}) + opts.push({value: "yaml", label: "YAML"}) } else if (expectedKind === "object") { opts.push({value: "form", label: "Form", hint: "default"}) + opts.push({value: "json", label: "JSON"}) + opts.push({value: "yaml", label: "YAML"}) } - opts.push({value: "json", label: "JSON"}) - opts.push({value: "yaml", label: "YAML"}) return opts } diff --git a/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts index 59b32d8530..9f44267519 100644 --- a/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts @@ -183,7 +183,9 @@ describe("view-types: expected-type-aware variants", () => { it("falls back to expectedType when value is undefined", () => { // Draft `geo` port referenced via `{{geo.region}}` → object port. expect(getDefaultViewForExpectedType(undefined, "object")).toBe("form") - expect(getDefaultViewForExpectedType(undefined, "array")).toBe("form") + // Arrays default to JSON instead of Form — empty arrays have no + // add-item affordance in Form view. See the dedicated test below. + expect(getDefaultViewForExpectedType(undefined, "array")).toBe("json") expect(getDefaultViewForExpectedType(undefined, "boolean")).toBe("text") expect(getDefaultViewForExpectedType(undefined, "number")).toBe("text") expect(getDefaultViewForExpectedType(undefined, "string")).toBe("text") @@ -219,6 +221,23 @@ describe("view-types: expected-type-aware variants", () => { expect(opts[0]?.value).toBe("text") expect(opts.map((o) => o.value)).toEqual(expect.arrayContaining(["markdown", "json"])) }) + + it("array drafts default to JSON (not Form) — Form has no add-item affordance", () => { + // Empty arrays in Form view show "(empty object)" with no way to + // add items. JSON's `[]` buffer is the more useful entry point. + expect(getDefaultViewForExpectedType(undefined, "array")).toBe("json") + const opts = getViewOptionsForExpectedType(undefined, "array") + expect(opts[0]?.value).toBe("json") + // Form is still in the list — useful once the array has items. + expect(opts.map((o) => o.value)).toEqual(expect.arrayContaining(["form", "yaml"])) + }) + + it("array drafts switch to value-driven options once a real array exists", () => { + // Real array → value-driven path: Form is default for objects/arrays + // with items (FormView renders them per-index). + const arr = ["en", "fr"] + expect(getDefaultViewForExpectedType(arr, "array")).toBe("form") + }) }) describe("view-types: buildEmptyShapeFromSchema", () => { From 2ad40f2128827068c90a67971db21a46bca79431 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 11:37:15 +0200 Subject: [PATCH 26/41] =?UTF-8?q?fix(frontend):=20restore=20"View=20as=20?= =?UTF-8?q?=E2=96=BE"=20trigger=20label,=20keep=20simplified=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous styling commit (83693af) over-corrected — moving to a plain antd Select dropped the "View as {mode}" prefix on the trigger, but the trigger label was the part that was working. The original beef was the LIST styling (the "SELECT HOW TO VIEW" group header and the "default" hint pills), not the trigger. Revert to the Dropdown + Button trigger reading "View as X ▾", but keep the menu items flat — no group header, no hint pills. Best of both: the trigger still discloses the dropdown's purpose, the menu matches the rest of the playground's lightweight dropdowns. --- .../src/view-types/ViewTypeSelect.tsx | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx index 33dd363846..a80f078da3 100644 --- a/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx @@ -3,15 +3,20 @@ * header. Lets the user switch render mode (text/markdown/chat/form/json/yaml) * for a single typed value. * - * Styled to match the rest of the playground's small dropdowns — the - * `Form / JSON / YAML` view picker in `PlaygroundVariantConfig` is the visual - * reference: a borderless `antd Select`, `size="small"`, no header label, - * no per-option hint pills. + * Trigger: text button reading "View as {Current Mode} ▾" (the "View as" + * prefix is intentional — it disambiguates the dropdown's purpose from the + * plain mode pickers used elsewhere). + * + * Menu: a plain flat list of options — no group header, no per-option hint + * pills. Matches the visual weight of the other small dropdowns in the + * playground (the prompt config view-mode picker is the reference). */ import {useMemo} from "react" -import {Select} from "antd" +import {CaretDown} from "@phosphor-icons/react" +import {Button, Dropdown} from "antd" +import type {MenuProps} from "antd" import type {ViewOption, ViewType} from "./viewTypes" @@ -20,12 +25,6 @@ interface ViewTypeSelectProps { options: ViewOption[] onChange: (value: ViewType) => void disabled?: boolean - /** Visual variant. Defaults to `"borderless"` — matches the prompt config - * view-mode dropdown. Use `"outlined"` for surfaces that want a chip - * border (rare). */ - variant?: "borderless" | "outlined" - /** Optional className passed through to the Select root. */ - className?: string } const VIEW_LABELS: Record = { @@ -37,36 +36,48 @@ const VIEW_LABELS: Record = { yaml: "YAML", } -export function ViewTypeSelect({ - value, - options, - onChange, - disabled, - variant = "borderless", - className, -}: ViewTypeSelectProps) { - const selectOptions = useMemo( +export function ViewTypeSelect({value, options, onChange, disabled}: ViewTypeSelectProps) { + const items: MenuProps["items"] = useMemo( () => options.map((opt) => ({ - value: opt.value, + key: opt.value, label: opt.label || VIEW_LABELS[opt.value], + onClick: () => onChange(opt.value), })), - [options], + [options, onChange], ) return ( - - size="small" - variant={variant} - value={value} - onChange={onChange} + + placement="bottomRight" + > + + ) } +const styles = { + trigger: { + display: "inline-flex", + alignItems: "center", + gap: 4, + padding: "0 8px", + height: 24, + borderRadius: 4, + fontSize: 12, + color: "#051729", + }, + triggerLabel: {color: "rgba(5, 23, 41, 0.55)"}, + triggerValue: {color: "#051729", fontWeight: 600}, + triggerCaret: {marginTop: 1, opacity: 0.65}, +} + export default ViewTypeSelect From 89f73b383bb17b68d3b9a5507ba29cc9a46c61f4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 11:43:56 +0200 Subject: [PATCH 27/41] =?UTF-8?q?fix(frontend):=20right-align=20per-field?= =?UTF-8?q?=20"View=20as=20=E2=96=BE"=20with=20card-level=20dropdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FormView`'s `formOuter` has `paddingRight: 20px` so the form's leaf cards (the input rectangles) sit inside that padding, away from the variable card's right border. But the labelRow holding each per-field `View as ▾` button also lived inside that padding, so the per-field dropdown sat 20px to the LEFT of the card-level dropdown above — a visible misalignment when scanning down the inputs. Stretch the labelRow past `formOuter.paddingRight` with a negative right margin so the per-field dropdown right-aligns with the card-level one. The field BODY (leafCard, nested rail, nested form fields) stays inside the padding, so the input rectangles keep their breathing room from the card border. Same fix applies to deeper nesting: `nestedRail` adds left padding only, so a nested field's labelRow has the same right edge as a top-level one — both extend 20px to the right via the same margin, both land at the card content's right edge. --- web/packages/agenta-entity-ui/src/view-types/FormView.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx index eee805f816..195b33075f 100644 --- a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx @@ -402,6 +402,14 @@ const styles = { justifyContent: "space-between", gap: 8, minWidth: 0, + // Stretch the label row past `formOuter`'s right padding so the + // per-field `View as ▾` button right-aligns with the card-level + // dropdown above (which sits flush with the card content's right + // edge). The field BODY (leafCard / nested rail / etc.) stays + // inside the padding so the input rectangle keeps its breathing + // room from the card border. Negative margin matches + // `formOuter.paddingRight` — keep them in sync if either changes. + marginRight: -20, }, labelLeft: { display: "flex", From c76295ee5072e4fe7c7c04706af839c9a7ec53ca Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 11:45:52 +0200 Subject: [PATCH 28/41] fix(frontend): nested FormView labels match parent VariableCard style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Children labels were stealing visual weight from their parent variable name — 14px / weight 600 / near-black / sans-serif inside a nested form, versus the parent card's name at 12px / weight 500 / blue / mono. `region` was screaming louder than `geo`. Unify nested field labels to the same vocabulary the parent uses: mono, 12px, weight 500, brand blue (#1677FF). The indent + 2px rail already communicates nesting; the label itself doesn't need to shout. Also swap the antd `` kind chip for the shared `TypeChip` component used in the parent card, mapping the nested 6-way kind to the chip vocabulary (`object` → `json-object`, `array` → `json-array`). Nested fields now show the SAME chip family as the parent — `object` chip with brand-blue tone instead of antd's gold. Result: visual hierarchy reads top-down — parent name + chip set the bar, children echo with the same look at the same weight. --- .../src/view-types/FormView.tsx | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx index 195b33075f..3256a09c49 100644 --- a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx @@ -25,7 +25,9 @@ import {useCallback, useMemo, useState, type ReactNode} from "react" import {SharedEditor} from "@agenta/ui/shared-editor" -import {Input, InputNumber, Switch, Tag} from "antd" +import {TypeChip} from "@agenta/ui/type-chip" +import type {ChipVariant} from "@agenta/ui/type-chip" +import {Input, InputNumber, Switch} from "antd" import {dump as yamlDump, load as yamlLoad} from "js-yaml" import { @@ -37,22 +39,17 @@ import { } from "./viewTypes" import {ViewTypeSelect} from "./ViewTypeSelect" -const NESTED_KIND_LABEL: Record = { +// Map the 6-way nested kind to the shared TypeChip vocabulary so nested +// field labels use the SAME chip the parent VariableCard renders — keeps +// the visual hierarchy consistent (parent name + chip → child name + chip +// with the same look). +const NESTED_KIND_CHIP: Record = { string: "string", number: "number", boolean: "boolean", null: "null", - object: "object", - array: "array", -} - -const NESTED_KIND_TONE: Record = { - string: "geekblue", - number: "blue", - boolean: "purple", - null: "default", - object: "gold", - array: "magenta", + object: "json-object", + array: "json-array", } interface FormViewProps { @@ -122,7 +119,6 @@ interface FormFieldProps { function FormField({label, value, depth, editable, onChange}: FormFieldProps) { const kind = detectNestedKind(value) - const labelStyle = depth === 0 ? styles.labelTop : styles.labelNested // For string fields we manage a per-field view mode (Text / Markdown / // JSON / YAML). The view-type selector lives in the label row, on the @@ -138,10 +134,16 @@ function FormField({label, value, depth, editable, onChange}: FormFieldProps) {
- - - {NESTED_KIND_LABEL[kind]} - + {/* Label style matches the parent `VariableCard`'s name: + * mono, 12px, weight 500, blue. The depth / nesting is + * communicated by the indentation + rail above — no need + * to shout with a heavier label. + * + * Children must NOT visually outweigh their parent — the + * parent's name + chip set the bar, the nested fields + * use the same vocabulary. */} + +
{isString ? ( Date: Wed, 27 May 2026 11:48:28 +0200 Subject: [PATCH 29/41] fix(frontend): drop FormView right padding so leaf cards align with labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix (89f73b383) used a negative margin-right on the label row to right-align the per-field `View as ▾` button with the card-level dropdown above — but that left the field BODY (leaf cards, nested rails, input rectangles) still indented inside `formOuter.paddingRight: 20`. So the buttons aligned but the input rectangles below them did NOT. Simpler fix: drop the right padding entirely. Now every form-rendered element — labels, View-as buttons, leaf input cards, nested rails — shares the same right edge as the card content. One consistent vertical line down the right side of every variable card. Removed the labelRow `marginRight: -20` compensation it was paired with — no longer needed once `formOuter.paddingRight` is 0. --- .../agenta-entity-ui/src/view-types/FormView.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx index 3256a09c49..1fbd99e71b 100644 --- a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx @@ -368,9 +368,15 @@ const styles = { // body), and the children sit indented behind it. Visually this // says "everything below belongs to the variable named in the // header above." + // + // No right padding — labels, leaf cards, and View-as buttons all + // extend to the card content's right edge so they share one + // vertical alignment with the card-level header above. Adding a + // padding-right here would push everything inside out of sync + // with the card-level dropdown. marginLeft: 20, paddingLeft: 16, - paddingRight: 20, + paddingRight: 0, borderLeft: RAIL, }, rootStack: { @@ -404,14 +410,6 @@ const styles = { justifyContent: "space-between", gap: 8, minWidth: 0, - // Stretch the label row past `formOuter`'s right padding so the - // per-field `View as ▾` button right-aligns with the card-level - // dropdown above (which sits flush with the card content's right - // edge). The field BODY (leafCard / nested rail / etc.) stays - // inside the padding so the input rectangle keeps its breathing - // room from the card border. Negative margin matches - // `formOuter.paddingRight` — keep them in sync if either changes. - marginRight: -20, }, labelLeft: { display: "flex", From 7150093073cbebc6d6b51169d44ace6b7fd34546 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 12:20:17 +0200 Subject: [PATCH 30/41] feat(frontend): align playground input cards with input-ux mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated changes to match the approved mockup: 1. ViewTypeSelect trigger drops the "View as" prefix - Reads just the current mode name ("String ↕", "Form ↕") - Two-way caret icon (CaretUpDown) instead of single down-caret - Menu list unchanged — already flat + clean from the earlier fix 2. Rename "Text" mode label to "String" - Underlying ViewType value stays as `"text"` — no API change - Visible everywhere the mode label surfaces: top-level dropdown trigger, dropdown menu items, nested FormView leaf dropdowns - "String" is the broader term — fits strings AND primitives (numbers/booleans/nulls) rendered as their text form 3. Revert nested field labels to dark bold sans-serif - Children labels are a DISTINCT concept (a property of the value) vs. the parent variable name (the input identifier the prompt references), so they shouldn't share the same style - Parent stays blue-mono-medium (the variable name) - Nested goes dark-bold-sans-serif at the same size (12px) — bold enough to read as "field name" without outweighing the parent 4. Unified action cluster on every VariableCard header - Copy-value button (always shown) - Database "Synced from {name}" indicator when the row is connected to a testset - Same cluster per card — uniform across the row, no per-card variation - Host reads `loadableController.selectors.connectedSource` and passes the source name through `PlaygroundInputsBody` → `VariableCard`. Per-card display, gated by the global loadable state. --- .../src/view-types/FormView.tsx | 13 ++-- .../src/view-types/ViewTypeSelect.tsx | 32 +++++----- .../src/view-types/viewTypes.ts | 8 +-- .../PlaygroundInputsBody.tsx | 6 ++ .../PlaygroundInputsBodyHost.tsx | 34 +++++++++- .../PlaygroundInputsBody/VariableCard.tsx | 64 ++++++++++++++++--- 6 files changed, 120 insertions(+), 37 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx index 1fbd99e71b..f3bed0f519 100644 --- a/web/packages/agenta-entity-ui/src/view-types/FormView.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/FormView.tsx @@ -417,16 +417,15 @@ const styles = { gap: 8, minWidth: 0, }, - /* Unified field label — same vocabulary as `VariableCard`'s name in - * the parent header (mono, 12px, weight 500, brand blue). Nesting is - * communicated by the indent + rail; children shouldn't outweigh - * their parent name. */ + /* Nested field label — distinguishes a PROPERTY of the value from the + * VARIABLE NAME (which the parent VariableCard renders in blue mono). + * Dark + bold + sans-serif at the parent name's size, so children + * don't outweigh the parent but still read as a distinct concept. */ fieldLabel: { - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: 12, - fontWeight: 500, + fontWeight: 600, lineHeight: "20px", - color: "#1677FF", + color: "#1f2937", margin: 0, }, nestedRail: { diff --git a/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx index a80f078da3..ec2f9b53da 100644 --- a/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx +++ b/web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx @@ -1,20 +1,20 @@ /** - * ViewTypeSelect — the "View as …" dropdown that sits in each field - * header. Lets the user switch render mode (text/markdown/chat/form/json/yaml) - * for a single typed value. + * ViewTypeSelect — the dropdown that sits in each field header. Lets the + * user switch render mode (text/markdown/chat/form/json/yaml) for a + * single typed value. * - * Trigger: text button reading "View as {Current Mode} ▾" (the "View as" - * prefix is intentional — it disambiguates the dropdown's purpose from the - * plain mode pickers used elsewhere). + * Trigger: text button reading the current mode (e.g. `String ↕`, `Form ↕`) + * with a two-way caret. The mode name speaks for itself — no "View as" + * prefix. * - * Menu: a plain flat list of options — no group header, no per-option hint + * Menu: plain flat list of options — no group header, no per-option hint * pills. Matches the visual weight of the other small dropdowns in the - * playground (the prompt config view-mode picker is the reference). + * playground. */ import {useMemo} from "react" -import {CaretDown} from "@phosphor-icons/react" +import {CaretUpDown} from "@phosphor-icons/react" import {Button, Dropdown} from "antd" import type {MenuProps} from "antd" @@ -27,8 +27,12 @@ interface ViewTypeSelectProps { disabled?: boolean } +// Visible labels for each ViewType. The underlying `text` value is shown to +// the user as "String" — covers strings, numbers, booleans, and nulls +// rendered as their primitive text form. The internal value name stays +// `"text"` to avoid an invasive rename across the codebase. const VIEW_LABELS: Record = { - text: "Text", + text: "String", markdown: "Markdown", chat: "Chat", form: "Form", @@ -55,10 +59,8 @@ export function ViewTypeSelect({value, options, onChange, disabled}: ViewTypeSel placement="bottomRight" > ) @@ -73,9 +75,7 @@ const styles = { height: 24, borderRadius: 4, fontSize: 12, - color: "#051729", }, - triggerLabel: {color: "rgba(5, 23, 41, 0.55)"}, triggerValue: {color: "#051729", fontWeight: 600}, triggerCaret: {marginTop: 1, opacity: 0.65}, } diff --git a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts index b69f8f0818..b6fe23a8d0 100644 --- a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts +++ b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts @@ -107,10 +107,10 @@ export function getViewOptions(value: unknown): ViewOption[] { const opts: ViewOption[] = [] if (kind === "string") { - opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "text", label: "String", hint: "default"}) opts.push({value: "markdown", label: "Markdown"}) } else if (kind === "boolean") { - opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "text", label: "String", hint: "default"}) } else if (kind === "chat") { opts.push({value: "chat", label: "Chat", hint: "default"}) } else if (kind === "object") { @@ -188,12 +188,12 @@ export function getViewOptionsForExpectedType( const opts: ViewOption[] = [] if (expectedKind === "string") { - opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "text", label: "String", hint: "default"}) opts.push({value: "markdown", label: "Markdown"}) opts.push({value: "json", label: "JSON"}) opts.push({value: "yaml", label: "YAML"}) } else if (expectedKind === "boolean") { - opts.push({value: "text", label: "Text", hint: "default"}) + opts.push({value: "text", label: "String", hint: "default"}) opts.push({value: "json", label: "JSON"}) opts.push({value: "yaml", label: "YAML"}) } else if (expectedType === "array") { diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx index d6bd21f0e6..f1ce24c435 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx @@ -103,6 +103,10 @@ export interface PlaygroundInputsBodyProps { unreferencedColumns?: PlaygroundInputsBodyVariable[] /** Whether referenced variable cards are editable. */ editable: boolean + /** When non-empty, every card shows a small database indicator in the + * header — `Synced from {name}`. Communicates that the row's data + * comes from a testset rather than being authored locally. */ + connectedSourceName?: string | null /** Writes the new value for an existing column to the testcase store. * Implementation should route through `testcaseMolecule.actions.update` * so the testcase entity is updated atomically. NATIVE value — no @@ -129,6 +133,7 @@ export function PlaygroundInputsBody({ onAddDraftColumn, onViewModeChange, unreferencedEditable = false, + connectedSourceName, }: PlaygroundInputsBodyProps) { // `sections` takes precedence over the flat `inputs` list. We still need // to look up by name to route draft edits, so unify the membership @@ -161,6 +166,7 @@ export function PlaygroundInputsBody({ editable={editable} onValueChange={handleValueChange} onViewModeChange={onViewModeChange} + connectedSourceName={connectedSourceName} /> ) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx index 69968687e0..4f0b59694d 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx @@ -20,8 +20,9 @@ import {useCallback, useMemo} from "react" -import {executionItemController} from "@agenta/playground" -import {useAtomValue, useSetAtom} from "jotai" +import {loadableController} from "@agenta/entities/runnable" +import {executionItemController, playgroundController} from "@agenta/playground" +import {atom, useAtomValue, useSetAtom} from "jotai" import {PlaygroundInputsBody} from "./PlaygroundInputsBody" import type { @@ -128,6 +129,34 @@ export function PlaygroundInputsBodyHost({ const setCellValue = useSetAtom(executionItemController.actions.setTestcaseCellValue) + // Connected-source name (set when the row is sourced from a testset + // rather than authored locally). Every card uses this to surface the + // unified database indicator — same per-card, gated by the global + // loadable state. + const loadableId = useAtomValue( + useMemo(() => playgroundController.selectors.loadableId(), []), + ) as string | null + // Build a single read-only atom that returns the connected-source + // descriptor (or null when no loadable is mounted). The cast through + // `unknown` keeps the conditional atom typeable — the runtime always + // returns the `{id, name, type}` shape (or null), but the atom-family + // factory has its own readonly atom type that wouldn't normally union + // cleanly with a fallback `atom(() => null)`. + const connectedSourceAtom = useMemo( + () => + loadableId + ? loadableController.selectors.connectedSource(loadableId) + : (atom(() => null) as unknown as ReturnType< + typeof loadableController.selectors.connectedSource + >), + [loadableId], + ) + const connectedSource = useAtomValue(connectedSourceAtom) as { + id: string | null + name: string | null + } | null + const connectedSourceName = connectedSource?.id ? (connectedSource.name ?? null) : null + const handleValueChange = useCallback( (name: string, value: unknown) => { setCellValue({testcaseId: rowId, column: name, value}) @@ -146,6 +175,7 @@ export function PlaygroundInputsBodyHost({ // Draft variables route through the same `setTestcaseCellValue` // reducer — it creates the new column on first set. onAddDraftColumn={handleValueChange} + connectedSourceName={connectedSourceName} /> ) } diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index cc901a4da6..ba49a477c0 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -41,8 +41,8 @@ import type {SimpleChatMessage} from "@agenta/ui/chat-message" import {SharedEditor} from "@agenta/ui/shared-editor" import {TypeChip} from "@agenta/ui/type-chip" import type {ChipVariant} from "@agenta/ui/type-chip" -import {Info} from "@phosphor-icons/react" -import {InputNumber, Switch, Tag, Tooltip, Typography} from "antd" +import {CopySimple, Database, Info} from "@phosphor-icons/react" +import {Button, InputNumber, Switch, Tag, Tooltip, Typography, message} from "antd" import clsx from "clsx" import {useAtom} from "jotai" @@ -84,6 +84,12 @@ interface VariableCardProps { * sub-fields without having to add them manually. Render-only — the * testcase value stays untouched until the user actually edits. */ expectedSchema?: unknown + /** When set, the card shows a small database indicator with a tooltip + * `Synced from {name}`. Communicates that this row's data comes from + * a testset rather than being authored locally. Unified across every + * card in the inputs body — the host either passes a name for all + * cards or none. */ + connectedSourceName?: string | null /** Whether the card is editable (vs read-only). */ editable: boolean /** Writes the new value to the testcase / draft store. NATIVE value. */ @@ -104,6 +110,7 @@ export function VariableCard({ helpText, expectedType, expectedSchema, + connectedSourceName, editable, onValueChange, onViewModeChange, @@ -154,6 +161,24 @@ export function VariableCard({ return buildEmptyShapeFromSchema(expectedSchema) }, [value, expectedSchema]) + // Copy the value as text. Primitives stringify naturally; structured + // values pretty-print as JSON. Drafts (no value yet) copy as empty + // string — defensive against undefined. + const handleCopy = useCallback(() => { + const text = + value === undefined || value === null + ? "" + : typeof value === "string" + ? value + : typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value, null, 2) + navigator.clipboard.writeText(text).then( + () => message.success({content: "Copied", duration: 1.5}), + () => message.error({content: "Copy failed", duration: 2}), + ) + }, [value]) + return (
@@ -190,12 +215,35 @@ export function VariableCard({ ) : null}
- +
+ {/* Unified action cluster — copy + (when connected) + * testset-sync indicator. Same set on every card so + * the row reads as a consistent block of inputs. */} + {connectedSourceName ? ( + + + + ) : null} + +
Date: Wed, 27 May 2026 13:36:16 +0200 Subject: [PATCH 31/41] fix(frontend): label/chip/controls live INSIDE the variable card border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were breaking the encapsulation cue: 1. The card border was `#e5e7eb` (too light) and missing an explicit `border-solid` — depending on which reset/theme was active, it could render invisibly. Bumped to `#d4d4d8` + explicit `border-solid` so the card boundary is clearly visible. 2. The inner SharedEditor / InputNumber were carrying their OWN prominent borders (`editorType="border"` for the editor, default antd InputNumber border). That inner border dominated the visual hierarchy — the header (label + chip + controls) looked like it was floating above a separate input box rather than sitting INSIDE the same card. Switched top-level primitive editors to borderless: - TextLeafEditor: editorType="border" → "borderless" - CodeLeafEditor: editorType="border" → "borderless" - InputNumber: variant="borderless" + `!px-0` Now the card's own border is the only visible boundary; the header and the value render as one connected block inside it. Nested FormView leaf cards keep their own borders unchanged — that's the right treatment for sub-elements within a parent form structure. --- .../components/PlaygroundInputsBody/VariableCard.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index ba49a477c0..78dfb5b967 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -180,7 +180,7 @@ export function VariableCard({ }, [value]) return ( -
+
@@ -343,15 +343,19 @@ function CardBody({mode, value, seedShape, editable, onChange}: CardBodyProps): } // text / markdown for primitives — use the right widget per actual type. + // Borderless: the variable card itself supplies the encapsulating border, + // so the inner widget should NOT carry its own — otherwise the input + // visually "floats" inside the card and reads as a separate element. if (originalType === "number" && mode === "text") { return ( onChange(next ?? null)} placeholder="Enter number" - className="w-full max-w-[320px]" + className="w-full max-w-[320px] !px-0" /> ) } @@ -405,7 +409,7 @@ function TextLeafEditor({mode, value, editable, originalType, onChange}: TextLea Date: Wed, 27 May 2026 13:42:44 +0200 Subject: [PATCH 32/41] fix(frontend): no inner border on hover/focus in VariableCard editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last commit (52967ae3) made the editors borderless at idle but the SharedEditor's borderless variant has hover/focus borders baked into `SharedEditorImpl` (lines 403, 416) that re-appear when the user moves the mouse over the card or focuses an input. That's the "disgusting" hover border in the screenshot — a nested border showing up inside the encapsulating card. Two changes: 1. Top-level text/markdown mode → replaced SharedEditor with antd `Input.TextArea` (variant="borderless"). The textarea has no built-in hover/focus border, no shadow, no padding. It melts into the variable card so the header + value read as one block — same as moving labels INTO the textarea, which was the design intent. We lose the rich-text affordances (tokens, markdown rendering) that SharedEditor provides for prompts, but variable VALUES are plain text — the rich features were never useful here. 2. JSON/YAML mode → kept SharedEditor (needed for codeOnly + line numbers) but flipped `state` to `"filled"` and added explicit `!border-transparent hover:!border-transparent focus:!border- transparent focus-within:!border-transparent` overrides on the wrapper. That beats the `isEditorFocused && "!border-[#BDC7D1]"` rule in SharedEditorImpl (which is `!important` and can't be overridden without matching specificity). The card border is now the ONLY visible boundary at every state — idle, hover, focused, typing. Header and value live inside the same enclosed block, as the design called for. --- .../PlaygroundInputsBody/VariableCard.tsx | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index 78dfb5b967..b443246f76 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -42,12 +42,14 @@ import {SharedEditor} from "@agenta/ui/shared-editor" import {TypeChip} from "@agenta/ui/type-chip" import type {ChipVariant} from "@agenta/ui/type-chip" import {CopySimple, Database, Info} from "@phosphor-icons/react" -import {Button, InputNumber, Switch, Tag, Tooltip, Typography, message} from "antd" +import {Button, Input, InputNumber, Switch, Tag, Tooltip, Typography, message} from "antd" import clsx from "clsx" import {useAtom} from "jotai" import {variableViewModeAtomFamily} from "./viewModeAtoms" +const {TextArea} = Input + const {Text: AntText} = Typography interface VariableCardProps { @@ -405,19 +407,22 @@ function TextLeafEditor({mode, value, editable, originalType, onChange}: TextLea [originalType, onChange], ) + // Plain TextArea with the borderless variant — no hover ring, no focus + // border, no padding, no shadow. The variable card supplies the only + // visible boundary; this editor melts into it so the label, controls, + // and value read as one block. return ( - handleChange(e.target.value)} + placeholder="Enter a value" + autoSize={{minRows: 2}} disabled={!editable} - state={editable ? undefined : "readOnly"} - placeholder="Enter value" - editorProps={{ - showToolbar: false, - }} + className={clsx( + "!p-0 !shadow-none !min-h-[40px] resize-none", + mode === "markdown" && "prose-sm", + )} /> ) } @@ -446,15 +451,21 @@ function CodeLeafEditor({mode, value, editable, onChange}: CodeLeafEditorProps) [mode, onChange], ) + // SharedEditor is needed here for code-only mode (line numbers + syntax + // highlighting). It's borderless, with `state="filled"` to suppress the + // built-in hover border, and explicit `!border-transparent` overrides to + // beat the `isEditorFocused && "!border-[#BDC7D1]"` rule baked into + // SharedEditorImpl. Result: NO inner border in any state — the variable + // card supplies the only boundary. return ( Date: Wed, 27 May 2026 13:46:59 +0200 Subject: [PATCH 33/41] fix(frontend): don't seed YAML buffer with flow-style `[]` / `{}` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js-yaml.dump([])` and `dump({})` only have a flow-style YAML representation, which renders identically to JSON literals. The user explicitly picked YAML mode for `languages` (an empty array draft) and saw `[]` — same as JSON. Looks broken / wrong. For empty containers, return an empty buffer in YAML mode instead of the flow-style literal. The editor's placeholder ("Enter YAML") then guides the user to type fresh YAML. As soon as the value has any content, `js-yaml.dump` produces proper block-style output (lists with `- `, mappings with `key: value`) — which is what users expect when they pick YAML. Strings that parse to empty containers get the same treatment. Test coverage: - valueToDisplay([], "yaml") === "" - valueToDisplay({}, "yaml") === "" - valueToDisplay("[]", "yaml") === "" - valueToDisplay(["en", "fr"], "yaml") still produces "- en\n- fr\n" - valueToDisplay({foo: "bar"}, "yaml") still produces "foo: bar\n" --- .../src/view-types/formatters.ts | 18 ++++++++++++++++ .../unit/playground-inputs-formatters.test.ts | 21 +++++++++++++++++++ .../PlaygroundInputsBody/VariableCard.tsx | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/formatters.ts b/web/packages/agenta-entity-ui/src/view-types/formatters.ts index 330414f2da..2e2f5b124e 100644 --- a/web/packages/agenta-entity-ui/src/view-types/formatters.ts +++ b/web/packages/agenta-entity-ui/src/view-types/formatters.ts @@ -17,6 +17,17 @@ import {dump as yamlDump, load as yamlLoad} from "js-yaml" import type {ViewType} from "./viewTypes" +/** True when the value is an empty array or empty object — the cases for + * which YAML and JSON `[]` / `{}` literals look identical and where seeding + * the YAML buffer doesn't help the user. */ +function isEmptyContainer(value: unknown): boolean { + if (Array.isArray(value)) return value.length === 0 + if (value !== null && typeof value === "object") { + return Object.keys(value as Record).length === 0 + } + return false +} + /* ── Display: native value → string ──────────────────────────────────── */ /** @@ -65,11 +76,18 @@ export function valueToDisplay(value: unknown, mode: ViewType): string { if (typeof value === "string") { try { const parsed = JSON.parse(value) + if (isEmptyContainer(parsed)) return "" return yamlDump(parsed, {noCompatMode: true, lineWidth: 100}) } catch { return value } } + // Empty containers (`[]` / `{}`) only have a flow-style YAML + // representation, which looks identical to JSON literals. Return + // an empty buffer instead so the editor's placeholder guides the + // user to type proper YAML; once they have actual data, + // `yamlDump` produces real block-style output. + if (isEmptyContainer(value)) return "" try { return yamlDump(value, {noCompatMode: true, lineWidth: 100}) } catch { diff --git a/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts index b6d07f948b..3b343733f3 100644 --- a/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts @@ -81,6 +81,27 @@ describe("formatters: valueToDisplay", () => { it("returns the raw string when it is not valid JSON", () => { expect(valueToDisplay("hello world", "yaml").trim()).toBe("hello world") }) + + it("returns empty string for empty containers (no flow-style `[]` / `{}` literal)", () => { + // `js-yaml.dump([])` and `dump({})` only produce flow-style + // literals because block style requires at least one item. The + // result looks identical to JSON — confusing the user when they + // explicitly picked YAML mode. Render as empty so the editor's + // placeholder takes over and the user types fresh YAML. + expect(valueToDisplay([], "yaml")).toBe("") + expect(valueToDisplay({}, "yaml")).toBe("") + // Strings that PARSE to empty containers get the same treatment. + expect(valueToDisplay("[]", "yaml")).toBe("") + expect(valueToDisplay("{}", "yaml")).toBe("") + }) + + it("still dumps non-empty arrays / objects as block-style YAML", () => { + const arr = valueToDisplay(["en", "fr"], "yaml") + expect(arr).toContain("- en") + expect(arr).toContain("- fr") + const obj = valueToDisplay({foo: "bar"}, "yaml") + expect(obj).toContain("foo: bar") + }) }) }) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index b443246f76..0fd3dd485e 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -466,7 +466,7 @@ function CodeLeafEditor({mode, value, editable, onChange}: CodeLeafEditorProps) disableDebounce disabled={!editable} state={editable ? "filled" : "readOnly"} - placeholder={mode === "json" ? "{}" : ""} + placeholder={mode === "json" ? "{}" : "Enter YAML"} editorProps={{ codeOnly: true, language: mode, From 0bdfc93368174e31d1e1fd852762b8e7c315d770 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 13:50:12 +0200 Subject: [PATCH 34/41] fix(frontend): JSON / YAML always at the bottom of the view-mode list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array case had JSON pushed to the FIRST slot (because empty arrays default to JSON for UX reasons), which broke the consistent list ordering — every other kind had JSON/YAML at the bottom and the kind-specific modes at the top. Decouple list order from default mode: - `getViewOptionsForExpectedType` now follows ONE rule for every kind: kind-specific modes first (String / Markdown / Form), then JSON, then YAML at the bottom. - `getDefaultViewForExpectedType` no longer reads `options[0]`. It encodes the default per expectedType directly (object → form, array → json, primitives → text), so arrays can still default to JSON without yanking JSON out of its list slot. Result, for any draft: string → [String, Markdown, JSON, YAML] object → [Form, JSON, YAML] array → [Form, JSON, YAML] ← consistent with object now boolean → [String, JSON, YAML] Array default mode unchanged (still JSON for empty arrays, Form once the value has items). --- .../src/view-types/viewTypes.ts | 49 ++++++++++--------- .../tests/unit/view-types.test.ts | 23 +++++++-- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts index b6fe23a8d0..429c2f258a 100644 --- a/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts +++ b/web/packages/agenta-entity-ui/src/view-types/viewTypes.ts @@ -173,10 +173,13 @@ function fieldKindFromExpected(expected: ExpectedType): FieldKind | null { * dropdown is built from `expectedType` instead. This is how a draft * variable known to be an object opens as `Form` rather than `Text`. * - * Array drafts intentionally default to JSON: Form view has no good - * empty-array UX (no "add item" affordance for `[]`), so JSON's `[]` - * buffer is more useful. Form stays in the list for editing once items - * exist. + * Ordering convention (consistent across every expectedType): the + * kind-specific modes go first, JSON and YAML always live at the BOTTOM. + * Strings get `[String, Markdown, JSON, YAML]`, objects/arrays get + * `[Form, JSON, YAML]`, booleans get `[String, JSON, YAML]`. The default + * mode is decoupled from list order — see `getDefaultViewForExpectedType` + * — so array drafts can still default to JSON without yanking JSON out + * of its conventional bottom-of-the-list slot. */ export function getViewOptionsForExpectedType( value: unknown, @@ -190,37 +193,39 @@ export function getViewOptionsForExpectedType( if (expectedKind === "string") { opts.push({value: "text", label: "String", hint: "default"}) opts.push({value: "markdown", label: "Markdown"}) - opts.push({value: "json", label: "JSON"}) - opts.push({value: "yaml", label: "YAML"}) } else if (expectedKind === "boolean") { opts.push({value: "text", label: "String", hint: "default"}) - opts.push({value: "json", label: "JSON"}) - opts.push({value: "yaml", label: "YAML"}) - } else if (expectedType === "array") { - // JSON first for empty arrays — FormView can't surface an "add - // item" affordance for an empty `[]`, so JSON's editable buffer - // is the more useful entry point. Form stays available for once - // items exist. - opts.push({value: "json", label: "JSON", hint: "default"}) - opts.push({value: "form", label: "Form"}) - opts.push({value: "yaml", label: "YAML"}) } else if (expectedKind === "object") { - opts.push({value: "form", label: "Form", hint: "default"}) - opts.push({value: "json", label: "JSON"}) - opts.push({value: "yaml", label: "YAML"}) + opts.push({value: "form", label: "Form"}) } + // JSON / YAML always at the bottom, in this order, for every kind. + opts.push({value: "json", label: "JSON"}) + opts.push({value: "yaml", label: "YAML"}) return opts } /** - * Default view picked the same way as `getDefaultViewForValue`, but with - * `expectedType` as a fallback when the value is empty. + * Default view mode for a typed draft. Independent of list order — + * `getViewOptionsForExpectedType` keeps a consistent layout regardless + * of which mode is the default. + * + * - object → Form (seeded with empty-shape when a schema is known) + * - array → JSON (FormView has no add-item affordance for `[]`, + * JSON's buffer is the better empty-state UX) + * - string/number/integer/boolean → text ("String" label) */ export function getDefaultViewForExpectedType( value: unknown, expectedType: ExpectedType, ): ViewType { - return getViewOptionsForExpectedType(value, expectedType)[0]?.value ?? "json" + if (!isValueEmpty(value)) return getDefaultViewForValue(value) + if (expectedType === "object") return "form" + if (expectedType === "array") return "json" + if (expectedType === "boolean") return "text" + if (expectedType === "string" || expectedType === "number" || expectedType === "integer") { + return "text" + } + return getDefaultViewForValue(value) } // ─── Empty-shape seed from JSON schema ───────────────────────────────────── diff --git a/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts index 9f44267519..3767c2e465 100644 --- a/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/view-types.test.ts @@ -225,11 +225,12 @@ describe("view-types: expected-type-aware variants", () => { it("array drafts default to JSON (not Form) — Form has no add-item affordance", () => { // Empty arrays in Form view show "(empty object)" with no way to // add items. JSON's `[]` buffer is the more useful entry point. + // BUT: list ordering keeps Form first (kind-specific), JSON/YAML + // at the bottom — same convention as every other kind. Default + // mode is decoupled from list order. expect(getDefaultViewForExpectedType(undefined, "array")).toBe("json") const opts = getViewOptionsForExpectedType(undefined, "array") - expect(opts[0]?.value).toBe("json") - // Form is still in the list — useful once the array has items. - expect(opts.map((o) => o.value)).toEqual(expect.arrayContaining(["form", "yaml"])) + expect(opts.map((o) => o.value)).toEqual(["form", "json", "yaml"]) }) it("array drafts switch to value-driven options once a real array exists", () => { @@ -238,6 +239,22 @@ describe("view-types: expected-type-aware variants", () => { const arr = ["en", "fr"] expect(getDefaultViewForExpectedType(arr, "array")).toBe("form") }) + + it("keeps JSON / YAML at the bottom for every expectedType (consistency)", () => { + // The dropdown should read the same regardless of which type the + // draft is — kind-specific modes first, JSON then YAML at the end. + const string = getViewOptionsForExpectedType(undefined, "string") + expect(string.map((o) => o.value)).toEqual(["text", "markdown", "json", "yaml"]) + + const object = getViewOptionsForExpectedType(undefined, "object") + expect(object.map((o) => o.value)).toEqual(["form", "json", "yaml"]) + + const array = getViewOptionsForExpectedType(undefined, "array") + expect(array.map((o) => o.value)).toEqual(["form", "json", "yaml"]) + + const boolean = getViewOptionsForExpectedType(undefined, "boolean") + expect(boolean.map((o) => o.value)).toEqual(["text", "json", "yaml"]) + }) }) describe("view-types: buildEmptyShapeFromSchema", () => { From acf98ab92a5349cc0a9957e499301acf63862774 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 14:29:22 +0200 Subject: [PATCH 35/41] feat(frontend): typeahead suggests field names after `{{#` / `{{^` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token typeahead opened on `{{` and worked for flat (`{{co...`) and JSONPath (`{{$.in...`) modes, but didn't fire when the user typed `{{#` (mustache section opener) — the `#` query didn't match any field name and the dropdown stayed closed. So authoring an iteration block (`{{#languages}}...{{/languages}}`) required typing the name from memory. Extend `parsePathContext` to recognize `#` and `^` prefixes as two new modes: `{{#` → mode: "section" `{{#la` → mode: "section", current: "la" `{{#user.` → mode: "section", prefix: ["user"], current: "" `{{^empty` → mode: "inverted-section", current: "empty" Section modes share the same suggestion sources as flat mode (port schemas + testcase columns + observed sub-keys) — the section name is a field on the input context, so the candidate set is identical. The differing piece is the inserted token text: `{{#${name}}}` for section, `{{^${name}}}` for inverted, handled in `push()` via the mode switch. Mustache `#name` works for arrays (iteration), objects (context switch), AND truthy primitives, so we don't filter the candidates by type — any field is a valid section target. Auto-pairing with a `{{/name}}` close tag is a separate UX improvement (current tokens are single nodes; pairing needs a second insert). Exported `parsePathContext` so the new modes can be unit-tested in a future commit (no vitest in @agenta/ui yet). --- .../plugins/token/TokenTypeaheadPlugin.tsx | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx index 0593aeeb2b..d765d91c93 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx @@ -34,16 +34,22 @@ interface Suggestion { hint?: string } -type PathMode = "path" | "flat" +type PathMode = "path" | "flat" | "section" | "inverted-section" interface PathContext { /** - * "path" → user is authoring a JSONPath expression rooted at `$`. - * "flat" → user is authoring a plain curly token (e.g. `{{country}}`, - * `{{country.region}}`); resolves at runtime against the - * flattened `inputs` kwargs by the SDK template engine, so - * we drill into the `inputs` envelope implicitly when - * asking the suggestions consumer for candidates. + * "path" → user is authoring a JSONPath expression rooted at `$`. + * "flat" → user is authoring a plain curly token (`{{country}}`, + * `{{country.region}}`); resolves at runtime against + * the flattened `inputs` kwargs by the SDK template + * engine, so we drill into the `inputs` envelope + * implicitly when asking the suggestions consumer. + * "section" → mustache section opener: `{{#name}}`. Same + * suggestion sources as flat mode (the name is a + * field on the input context), but the inserted + * token text wraps as `{{#name}}`. + * "inverted-section" → mustache inverted section opener: `{{^name}}`. + * Same sources as "section"; inserted as `{{^name}}`. */ mode: PathMode /** Path segments already committed before the current input (e.g. ["inputs"] when typing `$.inputs.ar`). */ @@ -70,8 +76,14 @@ interface PathContext { * `co` → {mode:"flat", prefix: [], current: "co"} * `country.` → {mode:"flat", prefix: ["country"], current: ""} * `country.re` → {mode:"flat", prefix: ["country"], current: "re"} + * + * Section-mode examples (`#` / `^` prefix — mustache only): + * `#` → {mode:"section", prefix: [], current: ""} + * `#la` → {mode:"section", prefix: [], current: "la"} + * `#user.` → {mode:"section", prefix: ["user"], current: ""} + * `^empty` → {mode:"inverted-section", prefix: [], current: "empty"} */ -function parsePathContext(input: string): PathContext { +export function parsePathContext(input: string): PathContext { if (input.startsWith("$")) { const body = input.replace(/^\$\.?/, "") if (body === "" && input === "$") return {mode: "path", prefix: [], current: ""} @@ -81,6 +93,15 @@ function parsePathContext(input: string): PathContext { const current = endsOnBoundary ? "" : (segments[segments.length - 1] ?? "") return {mode: "path", prefix, current} } + if (input.startsWith("#") || input.startsWith("^")) { + const mode: PathMode = input.startsWith("#") ? "section" : "inverted-section" + const body = input.slice(1) + const endsOnBoundary = body.endsWith(".") || body.endsWith("[") + const segments = body.split(/[.[\]'"]/).filter(Boolean) + const prefix = endsOnBoundary ? segments : segments.slice(0, -1) + const current = endsOnBoundary ? "" : (segments[segments.length - 1] ?? "") + return {mode, prefix, current} + } const endsOnBoundary = input.endsWith(".") || input.endsWith("[") const segments = input.split(/[.[\]'"]/).filter(Boolean) const prefix = endsOnBoundary ? segments : segments.slice(0, -1) @@ -182,7 +203,13 @@ export function TokenMenuPlugin({tokens}: TokenMenuPluginProps) { const body = [...prefix, label].join(".") const trailing = appendDot ? "." : "" const tokenText = - mode === "path" ? `{{$.${body}${trailing}}}` : `{{${body}${trailing}}}` + mode === "path" + ? `{{$.${body}${trailing}}}` + : mode === "section" + ? `{{#${body}${trailing}}}` + : mode === "inverted-section" + ? `{{^${body}${trailing}}}` + : `{{${body}${trailing}}}` results.push({label, tokenText, hint}) } @@ -216,9 +243,13 @@ export function TokenMenuPlugin({tokens}: TokenMenuPluginProps) { return results } - // Flat-mode: ask the consumer for suggestions as if we were - // drilling into `$.inputs.`. Lets a single source - // (testcase columns, port schema sub-paths) feed both modes. + // Flat-mode AND section/inverted-section modes — all three share the + // same suggestion source. The section name is a field on the input + // context (mustache `#name` works for any truthy value: arrays + // iterate, objects context-switch, primitives render the block once), + // so the candidate set is the same as flat mode. The differing piece + // is the inserted tokenText, handled in `push()` above via the `mode` + // switch. if (getContextSuggestions) { const provided = getContextSuggestions(["inputs", ...prefix], current) for (const s of provided) push(s.label, {hint: s.hint}) From 9ca1d8fd533cd95dd788ff1efef3d753345f88c4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 14:58:01 +0200 Subject: [PATCH 36/41] fix(frontend): JSON / YAML modes never auto-parse strings (gap-04 at display) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA flagged this twice — Mahmoud's May 21st video on #4394 ("if I move this to JSON, why is it now saying it's an object") and the 2026-05-27 chat editor screenshot ("This bug from QA has not been resolved. We still mistake stringified JSONS to Objects!"). Root cause: `valueToDisplay` had a `typeof value === "string"` branch in JSON and YAML modes that tried `JSON.parse(value)` first and, on success, dumped the parsed object. So a STRING column whose content happened to be JSON-shaped (the `metadata` cell `'{"source":...}'`) got rendered as a multi-line OBJECT in JSON mode and a YAML mapping in YAML mode. At display time the user couldn't tell strings from objects; type was silently changing. Drop the string special-case in both modes. Always call `JSON.stringify(value, null, 2)` (json) or `yamlDump(value, ...)` (yaml) directly. View modes are now pure REPRESENTATION transforms — they never change the value's type. Renders, after the fix: string "Vanuatu" json → "Vanuatu" (JSON literal, quoted) yaml → Vanuatu (YAML plain scalar) string '{"a":1}' json → "{\"a\":1}" (escaped, STILL a string) yaml → '{"a":1}' (YAML scalar, quoted) object {a: 1} json → {\n "a": 1\n} (multi-line) yaml → a: 1 (block style) number 42 json → 42 (literal) boolean true json → true (literal) Also fixes the secondary bug Mahmoud's video called out — that JSON mode for a plain string `Vanuatu` rendered raw unquoted text, which the JSON code editor flagged as a syntax error. Now it's a valid JSON string literal. Tests updated to pin the new behavior: - JSON: strings are JSON-literals, JSON-shaped strings escape (don't parse) - YAML: strings are YAML scalars, JSON-shaped strings stay strings - `"[]"` as a string survives the empty-container suppression (only actual empty arrays / objects get suppressed) The fix lands in the shared formatter — playground AND testset drawer (both consume `valueToDisplay` via FormView) get the gap-04 invariant back at display time. --- .../src/view-types/formatters.ts | 52 +++++++++---------- .../unit/playground-inputs-formatters.test.ts | 42 +++++++++++---- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/view-types/formatters.ts b/web/packages/agenta-entity-ui/src/view-types/formatters.ts index 2e2f5b124e..c84b74b096 100644 --- a/web/packages/agenta-entity-ui/src/view-types/formatters.ts +++ b/web/packages/agenta-entity-ui/src/view-types/formatters.ts @@ -33,13 +33,23 @@ function isEmptyContainer(value: unknown): boolean { /** * Render a value as a string for display in an editor, per view mode. * - * - text/markdown: primitives stringify naturally; objects/arrays show as - * compact JSON (matches the runtime's `{{var}}` rendering for whole-object - * insertion). - * - json: pretty-printed JSON (objects/arrays as object/array literal; strings - * that already contain JSON-shaped text get pretty-printed too). - * - yaml: YAML dump of the native value, falling back to raw string if the - * value isn't safely convertible. + * View modes are pure REPRESENTATION transforms — they NEVER change the + * value's type. A string is a string in every mode; an object is an object + * in every mode. Per the gap-04 invariant ("native JSON stays native"), we + * never auto-parse a JSON-shaped string into an object for display. + * + * - text / markdown: primitives stringify naturally; objects / arrays show + * as compact JSON (matches the runtime's `{{var}}` rendering for + * whole-object insertion). + * - json: `JSON.stringify(value, null, 2)` regardless of type. + * - string "Vanuatu" → `"Vanuatu"` (JSON literal, quoted) + * - string '{"a":1}' → `"{\"a\":1}"` (escaped JSON literal — still a STRING) + * - object {a: 1} → `{\n "a": 1\n}` (multi-line) + * - array ["a", "b"] → `[\n "a",\n "b"\n]` + * - number 42 → `42` + * - boolean true → `true` + * - yaml: `yamlDump(value)` regardless of type. YAML's plain scalars cover + * primitives; objects / arrays produce proper block-style YAML. * * Returns `""` for `null` and `undefined` so the editor renders empty. */ @@ -57,14 +67,10 @@ export function valueToDisplay(value: unknown, mode: ViewType): string { } if (mode === "json") { - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) - return JSON.stringify(parsed, null, 2) - } catch { - return value - } - } + // No string special-case — strings get JSON-encoded into a literal + // (`"value"` with internal escapes if needed). If the string happens + // to contain JSON-shaped text, we still render it as a STRING + // literal; the type doesn't silently change at display time. try { return JSON.stringify(value, null, 2) } catch { @@ -73,21 +79,15 @@ export function valueToDisplay(value: unknown, mode: ViewType): string { } if (mode === "yaml") { - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) - if (isEmptyContainer(parsed)) return "" - return yamlDump(parsed, {noCompatMode: true, lineWidth: 100}) - } catch { - return value - } - } // Empty containers (`[]` / `{}`) only have a flow-style YAML // representation, which looks identical to JSON literals. Return // an empty buffer instead so the editor's placeholder guides the - // user to type proper YAML; once they have actual data, - // `yamlDump` produces real block-style output. + // user to type proper YAML. if (isEmptyContainer(value)) return "" + // No string special-case here either — yamlDump handles strings as + // plain scalars (`Vanuatu`) and JSON-shaped strings as quoted + // scalars when needed (`'{"a":1}'`). Type preservation, same as + // JSON mode. try { return yamlDump(value, {noCompatMode: true, lineWidth: 100}) } catch { diff --git a/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts index 3b343733f3..ced27e4879 100644 --- a/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts @@ -52,17 +52,25 @@ describe("formatters: valueToDisplay", () => { expect(valueToDisplay([1, 2], "json")).toBe("[\n 1,\n 2\n]") }) - it("pretty-prints strings that ARE valid JSON", () => { - expect(valueToDisplay('{"a":1}', "json")).toBe('{\n "a": 1\n}') + it("renders strings as JSON-literal (gap-04: strings stay strings — never parse)", () => { + // Plain string → quoted JSON literal. Crucially NOT the raw + // unquoted text, which the JSON code editor flags as a syntax + // error. + expect(valueToDisplay("Vanuatu", "json")).toBe('"Vanuatu"') }) - it("returns the raw string when it is NOT valid JSON", () => { - expect(valueToDisplay("hello", "json")).toBe("hello") + it("renders JSON-shaped strings AS STRINGS (never auto-parse into objects)", () => { + // The metadata bug Mahmoud flagged: stringified JSON must NOT be + // mistaken for an object. The display preserves the string type + // by JSON-encoding the string literal (outer quotes, escaped + // inner quotes) instead of parsing + pretty-printing. + expect(valueToDisplay('{"a":1}', "json")).toBe('"{\\"a\\":1}"') }) it("stringifies primitives as JSON literals", () => { expect(valueToDisplay(42, "json")).toBe("42") expect(valueToDisplay(true, "json")).toBe("true") + expect(valueToDisplay(false, "json")).toBe("false") }) }) @@ -73,12 +81,21 @@ describe("formatters: valueToDisplay", () => { expect(out).toContain("b: two") }) - it("dumps strings that ARE valid JSON as YAML", () => { + it("dumps strings AS STRINGS (never auto-parse JSON-shaped strings)", () => { + // gap-04: type preservation in display. A string containing + // JSON-shaped text gets yamlDump'd as a YAML scalar (quoted + // because the leading `{` would otherwise be ambiguous), NOT + // converted to a YAML mapping. const out = valueToDisplay('{"a":1}', "yaml") - expect(out).toContain("a: 1") + // The result is a YAML scalar — quoted or escaped depending on + // js-yaml's choice, but it must NOT produce a `a: 1` mapping. + expect(out).not.toMatch(/^a:\s/m) + // And the original string content survives a YAML re-parse. + // (Sanity check that we're dumping the string, not anything else.) + expect(out).toContain('{"a":1}') }) - it("returns the raw string when it is not valid JSON", () => { + it("dumps plain strings as YAML plain scalars", () => { expect(valueToDisplay("hello world", "yaml").trim()).toBe("hello world") }) @@ -90,9 +107,14 @@ describe("formatters: valueToDisplay", () => { // placeholder takes over and the user types fresh YAML. expect(valueToDisplay([], "yaml")).toBe("") expect(valueToDisplay({}, "yaml")).toBe("") - // Strings that PARSE to empty containers get the same treatment. - expect(valueToDisplay("[]", "yaml")).toBe("") - expect(valueToDisplay("{}", "yaml")).toBe("") + }) + + it("preserves string-typed empty-container LITERALS (gap-04)", () => { + // `"[]"` is a STRING — it should NOT be parsed and then + // emptied. It survives as a YAML string scalar. + const out = valueToDisplay("[]", "yaml") + expect(out).not.toBe("") // not suppressed (it's a string, not an empty array) + expect(out).toContain("[]") }) it("still dumps non-empty arrays / objects as block-style YAML", () => { From 8d088ad3470de0f8c317e0540bd79c511de90804 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 16:37:55 +0200 Subject: [PATCH 37/41] fix(frontend): chat-mode variable cards are editable (match MessagesField) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `messages` variable rendered in chat mode showed message content but clicks didn't focus / typing didn't register — read-only despite `disabled={!editable}` evaluating to false. Root cause: my CardBody passed only `messages`, `onChange`, `disabled` to `ChatMessageList`. The proven-working consumer (`MessagesField` in the drill-in drawer) passes `enableTokens` + `templateFormat` + `allowFileUpload={false}`. Without `enableTokens` / `templateFormat`, the underlying Lexical editor mounts its plugin stack in a stripped- down state where the content surface effectively reads as static text. Mirror MessagesField's prop set: enableTokens (so `{{var}}` tokens are recognized inside chat message content — also makes the editor mount the editable plugin path) templateFormat (paired with enableTokens — curly for now; could thread the prompt's actual template_format later) allowFileUpload (false — variable values don't take inline attachments; attachments live in their own testcase column shape) No tests touched — ChatMessageList interactions need a DOM environment and aren't in scope of the formatter / view-types suites. --- .../components/PlaygroundInputsBody/VariableCard.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index 0fd3dd485e..dca999aedd 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -323,11 +323,21 @@ function CardBody({mode, value, seedShape, editable, onChange}: CardBodyProps): if (mode === "chat") { const messages = isChatMessagesArray(value) ? (value as SimpleChatMessage[]) : [] + // Match the prop set used by `MessagesField` (drill-in drawer) which + // is the canonical working configuration for editing chat-shaped + // arrays. Without `enableTokens` / `templateFormat` the message + // editors mount their plugin stack in a constrained state and the + // content reads as static text. `allowFileUpload={false}` matches + // the variable-input UX — files belong in their own column, not + // inline in a testcase value. return ( onChange(next)} disabled={!editable} + enableTokens + templateFormat="curly" + allowFileUpload={false} /> ) } From d43c52eaf6f4c4b3b5eabe88325277d8b0807420 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 16:48:56 +0200 Subject: [PATCH 38/41] fix(frontend): unreferenced columns are editable when expanded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sorry for the wrong-direction debug pass on chat editability — the disabled state came from a different prop entirely. `UnreferencedColumnsFooter` renders the "N unused testcase columns hidden..." block. Its `editable` prop defaults to `false` (defensive — some display surfaces want unreferenced columns read-only), and the Host wasn't passing it. So when the user expanded the footer, every card inside rendered with `editable={false}` → `disabled={true}` on every editor inside, including ChatMessageList. That matches the user's screenshot: `messages` was sitting under the "5 unused testcase columns hidden" footer (the prompt only references `country` / `geo` / `metadata`), so it was an UNREFERENCED column — and unreferenced columns were read-only. Fix: pass `unreferencedEditable={editable}` from the Host. Once the user expands the footer, the cards behave the same as referenced ones — same editor, same interactions. The footer's existence is just a visibility-rule signal ("the prompt doesn't reference these"), not a permission boundary. --- .../PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx index 4f0b59694d..052d8e646a 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx @@ -171,6 +171,13 @@ export function PlaygroundInputsBodyHost({ sections={bodySections} unreferencedColumns={visibility.unreferencedColumns} editable={editable} + // Unreferenced columns are testcase data the user authored — + // they should be editable when the footer is expanded, same as + // any other variable card. The prop defaults to `false` in + // `PlaygroundInputsBody` (defensive — read-only display + // surfaces can opt out), so we explicitly pass `editable` + // here to match the rest of the inputs body. + unreferencedEditable={editable} onValueChange={handleValueChange} // Draft variables route through the same `setTestcaseCellValue` // reducer — it creates the new column on first set. From 2eb1f940b2900afa07505bb4eb9cf3f3ff653504 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 17:09:27 +0200 Subject: [PATCH 39/41] fix(frontend): gate mustache-specific affordances by templateFormat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two FE behaviors were firing for ALL `{{...}}` formats — curly, jinja2, AND mustache — even though section / inverted-section / comment / partial syntax is mustache-only. So if a user authored a curly prompt with `{{#items}}` (mustache syntax pasted in error), the FE silently "helped" them by extracting `items` as a port and offering section- opener typeahead suggestions, while the BE would still error at render time. Mismatch. Fixes: 1. `extractTemplateVariables` — split per format: - mustache: strip `#`/`^`/`&` prefixes; skip `/`/`!`/`>`/`.` - curly / jinja2: skip any token whose first char is one of those mustache markers (`#^&/!>.`). They're not valid identifiers in these formats, so no phantom port — the user sees the broken token in the editor and gets clear feedback to fix it. 2. `TokenMenuPlugin` + `parsePathContext` — section / inverted-section modes are now mustache-only. The plugin reads templateFormat from TokenBehaviorConfig (already threaded through the lexical extension) and passes it to parsePathContext. In non-mustache the typeahead stays in flat mode for `{{#...`, the `#` becomes part of the literal query, no port name matches, no suggestions surface. Tests updated: - curly: `{{#items}}` / `{{^empty}}` / `{{!c}}` / etc. → [] - curly: dotted access `{{user.name}}` still extracts - curly: mixed `{{name}} {{#items}}` → ["name"] (legit survives) - mustache tests unchanged `extractMustacheSectionOpeners` was already format-gated correctly (no change needed). --- .../agenta-entities/src/runnable/utils.ts | 39 ++++++++++++++----- .../unit/extract-template-variables.test.ts | 34 +++++++++++++--- .../plugins/token/TokenTypeaheadPlugin.tsx | 28 ++++++++++--- .../token/extensions/tokenBehavior.tsx | 2 +- 4 files changed, 82 insertions(+), 21 deletions(-) diff --git a/web/packages/agenta-entities/src/runnable/utils.ts b/web/packages/agenta-entities/src/runnable/utils.ts index f989ba8f14..648e9b66b3 100644 --- a/web/packages/agenta-entities/src/runnable/utils.ts +++ b/web/packages/agenta-entities/src/runnable/utils.ts @@ -508,7 +508,7 @@ export function extractTemplateVariables( // curly, jinja2, and mustache all use {{variableName}} for variable substitution // Linear scan: find '{{', then find '}}', extract the content between them. // - // For mustache, normalise / skip block-level syntax: + // For MUSTACHE, normalise / skip block-level syntax: // // keep (strip prefix → variable name): // - `{{#name}}` — section opener: `name` IS a variable (the iterable @@ -522,6 +522,14 @@ export function extractTemplateVariables( // - `{{> partial}}` — partial template inclusion (resolved at render). // - `{{.}}` — implicit iterator (current item, no base name). // + // For CURLY / JINJA2, none of those prefix characters are valid in + // identifiers — those formats have no section semantics, no implicit + // iterator, no inline comments / partials inside `{{...}}`. If the user + // wrote `{{#items}}` in a curly prompt it's an authoring error (likely + // mustache syntax pasted in). Skip the extraction so no phantom port + // appears in the playground — the user sees the broken token in the + // editor without the FE silently masking it. + // // The TokenPlugin highlights all of these via its own regex — this filter // is for PORT DISCOVERY only. The mustache renderer pairs `#`/`^`/`/` // structurally at render time. @@ -532,16 +540,29 @@ export function extractTemplateVariables( const end = input.indexOf("}}", start) if (end !== -1) { const raw = input.slice(start, end).trim() - const isSkippablePrefix = /^[/!>]/.test(raw) - const isImplicitIterator = raw === "." - if (raw && !isSkippablePrefix && !isImplicitIterator) { - // Strip section-opener / unescape prefixes — the base name - // is the actual variable that needs a value. - const variable = /^[#^&]/.test(raw) ? raw.slice(1).trim() : raw - if (variable && !variables.includes(variable)) { - variables.push(variable) + + if (templateFormat === "mustache") { + const isSkippablePrefix = /^[/!>]/.test(raw) + const isImplicitIterator = raw === "." + if (raw && !isSkippablePrefix && !isImplicitIterator) { + // Strip section-opener / unescape prefixes — the base + // name is the actual variable that needs a value. + const variable = /^[#^&]/.test(raw) ? raw.slice(1).trim() : raw + if (variable && !variables.includes(variable)) { + variables.push(variable) + } + } + } else { + // curly / jinja2 — only identifier-shaped tokens count. + // Anything starting with mustache-style markers is an + // authoring error in these formats; skip it so the + // playground doesn't surface a port for the bad token. + const startsWithMustacheMarker = /^[#^&/!>.]/.test(raw) + if (raw && !startsWithMustacheMarker && !variables.includes(raw)) { + variables.push(raw) } } + i = end + 2 } else { // No closing '}}' found, no more variables possible diff --git a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts index 44e4205203..405438248a 100644 --- a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts +++ b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts @@ -101,15 +101,37 @@ describe("extractTemplateVariables", () => { }) describe("curly (legacy)", () => { - it("extracts {{name}} variables (same code path as mustache)", () => { + it("extracts {{name}} variables", () => { expect(extractTemplateVariables("Hello {{name}}", "curly")).toEqual(["name"]) }) - it("strips section-like prefixes too (defensive — curly doesn't use them)", () => { - // Curly doesn't have sections, but if a user pastes mustache - // syntax into a curly prompt, the same prefix-stripping rule - // surfaces the base name. `{{#items}}` → `items`. - expect(extractTemplateVariables("{{#items}}{{/items}}", "curly")).toEqual(["items"]) + it("extracts dotted access {{user.name}}", () => { + expect(extractTemplateVariables("Hello {{user.name}}", "curly")).toEqual([ + "user.name", + ]) + }) + + it("SKIPS mustache-style markers (no section semantics in curly)", () => { + // Curly has no section / inverted-section / comment / partial + // syntax. If the user types `{{#items}}` it's an authoring + // error (mustache syntax pasted in). Don't extract — the + // editor still highlights the bad token visually, but no + // phantom port appears in the playground. The user can fix + // the template instead of being silently "helped". + expect(extractTemplateVariables("{{#items}}{{/items}}", "curly")).toEqual([]) + expect(extractTemplateVariables("{{^empty}}none{{/empty}}", "curly")).toEqual([]) + expect(extractTemplateVariables("{{&unescaped}}", "curly")).toEqual([]) + expect(extractTemplateVariables("{{!comment}}", "curly")).toEqual([]) + expect(extractTemplateVariables("{{> partial}}", "curly")).toEqual([]) + expect(extractTemplateVariables("{{.}}", "curly")).toEqual([]) + }) + + it("still extracts plain variables next to a bad mustache token", () => { + // Mixed authoring: the legit `{{name}}` survives; the + // `{{#items}}` mistake is dropped. + expect(extractTemplateVariables("Hi {{name}}, list: {{#items}}.", "curly")).toEqual( + ["name"], + ) }) }) diff --git a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx index d765d91c93..a324aea403 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx @@ -77,13 +77,23 @@ interface PathContext { * `country.` → {mode:"flat", prefix: ["country"], current: ""} * `country.re` → {mode:"flat", prefix: ["country"], current: "re"} * - * Section-mode examples (`#` / `^` prefix — mustache only): + * Section-mode examples (`#` / `^` prefix — MUSTACHE ONLY): * `#` → {mode:"section", prefix: [], current: ""} * `#la` → {mode:"section", prefix: [], current: "la"} * `#user.` → {mode:"section", prefix: ["user"], current: ""} * `^empty` → {mode:"inverted-section", prefix: [], current: "empty"} + * + * In non-mustache formats, `{{#name}}` is an authoring error (no section + * semantics exist), so the section / inverted-section modes are suppressed. + * The plugin falls back to flat mode for those formats — the `#` / `^` + * appears as part of the literal query, matching nothing, so no + * suggestions surface and the user isn't tempted to author a broken + * template. */ -export function parsePathContext(input: string): PathContext { +export function parsePathContext( + input: string, + templateFormat: "mustache" | "curly" | "fstring" | "jinja2" = "curly", +): PathContext { if (input.startsWith("$")) { const body = input.replace(/^\$\.?/, "") if (body === "" && input === "$") return {mode: "path", prefix: [], current: ""} @@ -93,7 +103,7 @@ export function parsePathContext(input: string): PathContext { const current = endsOnBoundary ? "" : (segments[segments.length - 1] ?? "") return {mode: "path", prefix, current} } - if (input.startsWith("#") || input.startsWith("^")) { + if (templateFormat === "mustache" && (input.startsWith("#") || input.startsWith("^"))) { const mode: PathMode = input.startsWith("#") ? "section" : "inverted-section" const body = input.slice(1) const endsOnBoundary = body.endsWith(".") || body.endsWith("[") @@ -111,9 +121,14 @@ export function parsePathContext(input: string): PathContext { interface TokenMenuPluginProps { tokens: string[] + /** Active prompt template format. Section / inverted-section modes are + * mustache-only — for other formats `{{#...}}` is an authoring error, + * not a typeahead trigger. Defaults to `"curly"` to match the rest of + * the editor's defaults. */ + templateFormat?: "mustache" | "curly" | "fstring" | "jinja2" } -export function TokenMenuPlugin({tokens}: TokenMenuPluginProps) { +export function TokenMenuPlugin({tokens, templateFormat = "curly"}: TokenMenuPluginProps) { const [editor] = useLexicalComposerContext() const [anchor, setAnchor] = useState<{element: HTMLElement; key: string} | null>(null) const [selectedIndex, setSelectedIndex] = useState(0) @@ -151,7 +166,10 @@ export function TokenMenuPlugin({tokens}: TokenMenuPluginProps) { return Array.from(uniqueTokens).filter(Boolean) }, [tokens]) - const pathContext = useMemo(() => parsePathContext(inputQuery), [inputQuery]) + const pathContext = useMemo( + () => parsePathContext(inputQuery, templateFormat), + [inputQuery, templateFormat], + ) /** * Consumer-provided path suggestions (optional). The playground injects diff --git a/web/packages/agenta-ui/src/Editor/plugins/token/extensions/tokenBehavior.tsx b/web/packages/agenta-ui/src/Editor/plugins/token/extensions/tokenBehavior.tsx index b5846534b9..e04b530652 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/token/extensions/tokenBehavior.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/token/extensions/tokenBehavior.tsx @@ -33,7 +33,7 @@ function TokenBehaviorOverlay() { <> - + ) From f69a76eff79ea7ea34615c15634ced44fde90117 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 19:08:16 +0200 Subject: [PATCH 40/41] =?UTF-8?q?fix(frontend):=20CodeRabbit=20review=20pa?= =?UTF-8?q?ss=20=E2=80=94=20formatting=20+=20targeted=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address six CodeRabbit findings on PR #4465. Each in one focused diff: 1. **Prettier formatting** — `pnpm run format-fix` over the web/ tree. `extract-template-variables.test.ts` had a long line outside Prettier's wrap width and an unrelated EE env file was also stale. Both fixed. 2. **Buffer resync on external value changes** (VariableCard.tsx) `TextLeafEditor` and `CodeLeafEditor` seeded their local `buffer` from props once and never resynced. Testcase refetches / row updates left the visible text stale while `value` had already moved on. Added `useEffect(() => setBuffer(initial), [initial])` to both. 3. **Reject `$.` JSONPath** (agenta-shared/utils/templateVariable.ts) `hasEmptySegment` only catches duplicated separators (`..`, `//`), not a lone trailing one — so `$.` (root + trailing dot, no field) slipped through as valid. Only the bare `$` form is allowed; `$.` now returns `{valid: false, reason: "JSONPath root has no field after `$`."}` so the editor shows the invalid-token treatment. 4. **Envelope-scoped sectionOpeners lookup** (portHelpers.ts) `groupTemplateVariables` checked `sectionOpeners.has(key)` but the groups themselves are keyed by `${envelope}.${key}`. A section opener `{{#languages}}` would coerce BOTH `inputs.languages` AND `outputs.languages` (if both appeared in one prompt) to `array`. Resolve each opener through `parseTemplateExpression` first and key the lookup set by `${envelope}.${key}` — same identity the groups use. 5. **Cross-package import via path alias** (template-variable-validation.test.ts) Switched `"../../../agenta-shared/src/utils/templateVariable"` to `"@agenta/shared/utils"` (which re-exports `validateTemplateVariable` from the templateVariable module). Matches repo guideline: no relative paths for cross-package imports. 6. **Typeahead seen-token prefix handling** (TokenTypeaheadPlugin.tsx) Mustache section openers live in the editor as tokens with bodies like `#languages` / `^empty`. The previously-seen fallback loop was reading them verbatim: - In SECTION / inverted-section mode, `push()` re-prefixes, producing `{{##languages}}` / `{{^^empty}}`. - In FLAT mode, the prefixed token would leak into the flat suggestion list. Strip the leading `#` / `^` before prefix-matching + segment extraction (so section-mode selections produce clean tokens), and skip prefixed tokens entirely when `mode === "flat"`. Tests added: - validation: `$.` rejected with the "no field" reason - extract-template-variables.test.ts kept its existing coverage; the new behavior is exercised by existing curly / mustache tests after the Prettier-driven formatting tweak Full test suite: 455 / 455 entities, 79 / 79 entity-ui. tsc + lint clean across @agenta/entities, @agenta/entity-ui, @agenta/playground-ui, @agenta/shared, @agenta/ui. CodeRabbit comment 3 (`templateFormat` threading to ChatMessageList) is its own commit — it touches more files and needs the Host to read the active prompt format from playground state. --- .../src/runnable/portHelpers.ts | 17 +++++++++++++-- .../unit/extract-template-variables.test.ts | 21 +++++++------------ .../unit/template-variable-validation.test.ts | 14 +++++++++++-- .../PlaygroundInputsBody/VariableCard.tsx | 17 ++++++++++++++- .../src/utils/templateVariable.ts | 12 +++++++++-- .../plugins/token/TokenTypeaheadPlugin.tsx | 20 ++++++++++++++---- 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/web/packages/agenta-entities/src/runnable/portHelpers.ts b/web/packages/agenta-entities/src/runnable/portHelpers.ts index 46b2a85f32..64d338d10e 100644 --- a/web/packages/agenta-entities/src/runnable/portHelpers.ts +++ b/web/packages/agenta-entities/src/runnable/portHelpers.ts @@ -301,7 +301,19 @@ export function groupTemplateVariables( }, ): GroupedTemplateVariable[] { const groups = new Map}>() - const sectionOpeners = options?.sectionOpeners + + // Resolve section opener names through `parseTemplateExpression` and + // key them by envelope-scoped `${envelope}.${key}` ids — same identity + // the `groups` map uses below. Otherwise a section opener written as + // `{{#languages}}` would coerce BOTH `inputs.languages` AND + // `outputs.languages` (if both existed in the same prompt) to `array`. + const sectionOpenerIds = new Set() + if (options?.sectionOpeners) { + for (const opener of options.sectionOpeners) { + const parsed = parseTemplateExpression(opener) + if (parsed.key) sectionOpenerIds.add(`${parsed.envelope}.${parsed.key}`) + } + } for (const placeholder of placeholders) { // Invalid envelope references (e.g. `$.input.xx.abc` — `input` is not @@ -333,7 +345,8 @@ export function groupTemplateVariables( // template addresses specific fields). // 2. Section opener AND no sub-paths → `"array"` (iteration intent). // 3. Otherwise → `"string"`. - const isSectionOpener = sectionOpeners?.has(key) ?? false + const groupId = `${envelope}.${key}` + const isSectionOpener = sectionOpenerIds.has(groupId) const type: GroupedTemplateVariable["type"] = subPathList.length > 0 ? "object" : isSectionOpener ? "array" : "string" return { diff --git a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts index 405438248a..160d9a8984 100644 --- a/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts +++ b/web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts @@ -106,9 +106,7 @@ describe("extractTemplateVariables", () => { }) it("extracts dotted access {{user.name}}", () => { - expect(extractTemplateVariables("Hello {{user.name}}", "curly")).toEqual([ - "user.name", - ]) + expect(extractTemplateVariables("Hello {{user.name}}", "curly")).toEqual(["user.name"]) }) it("SKIPS mustache-style markers (no section semantics in curly)", () => { @@ -129,9 +127,9 @@ describe("extractTemplateVariables", () => { it("still extracts plain variables next to a bad mustache token", () => { // Mixed authoring: the legit `{{name}}` survives; the // `{{#items}}` mistake is dropped. - expect(extractTemplateVariables("Hi {{name}}, list: {{#items}}.", "curly")).toEqual( - ["name"], - ) + expect(extractTemplateVariables("Hi {{name}}, list: {{#items}}.", "curly")).toEqual([ + "name", + ]) }) }) @@ -156,10 +154,7 @@ describe("extractMustacheSectionOpeners", () => { }) it("picks up `{{#name}}` openers", () => { - const out = extractMustacheSectionOpeners( - "{{#languages}}{{.}}{{/languages}}", - "mustache", - ) + const out = extractMustacheSectionOpeners("{{#languages}}{{.}}{{/languages}}", "mustache") expect(Array.from(out)).toEqual(["languages"]) }) @@ -174,10 +169,8 @@ describe("extractMustacheSectionOpeners", () => { it("excludes closers, comments, partials, the implicit iterator, and plain vars", () => { expect( - extractMustacheSectionOpeners( - "{{/name}} {{!c}} {{> p}} {{.}} {{plain}}", - "mustache", - ).size, + extractMustacheSectionOpeners("{{/name}} {{!c}} {{> p}} {{.}} {{plain}}", "mustache") + .size, ).toBe(0) }) diff --git a/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts index b2ace04bcb..543a26a941 100644 --- a/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts +++ b/web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts @@ -7,11 +7,12 @@ * * Why these tests live in agenta-entities: agenta-shared has no vitest * runner of its own. Same stopgap pattern as the other tests in this - * folder. Cross-package relative import below is a test-time dep only. + * folder. We import via the workspace path alias rather than a relative + * path that would couple this test to the package's folder layout. */ import {describe, expect, it} from "vitest" -import {validateTemplateVariable} from "../../../agenta-shared/src/utils/templateVariable" +import {validateTemplateVariable} from "@agenta/shared/utils" describe("validateTemplateVariable", () => { describe("plain names + dot notation", () => { @@ -56,6 +57,15 @@ describe("validateTemplateVariable", () => { expect(validateTemplateVariable("$").valid).toBe(true) }) + it("rejects `$.` (root with trailing dot, no field)", () => { + // Only the bare `$` is a valid empty form. `$.` has nothing + // after the dot so it can never resolve at render time — + // surface that as an authoring error in the editor. + const result = validateTemplateVariable("$.") + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/has no field/i) + }) + it("rejects when the root looks like a typo of an envelope slot", () => { // `input` → `inputs`, `out` → `outputs` etc. These are the // actionable typo hints the editor surfaces. diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index dca999aedd..aa6217e6e6 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -22,7 +22,7 @@ * arrays, numbers as numbers, etc. */ -import {useCallback, useMemo, useState, type ReactNode} from "react" +import {useCallback, useEffect, useMemo, useState, type ReactNode} from "react" import { FormView, @@ -409,6 +409,14 @@ function TextLeafEditor({mode, value, editable, originalType, onChange}: TextLea const initial = useMemo(() => valueToDisplay(value, mode), [value, mode]) const [buffer, setBuffer] = useState(initial) + // Resync the local buffer when `value` changes externally — e.g. testcase + // refetch from the store, a row update from another surface, or a + // discard/revert that resets the cell. Without this the visible text + // goes stale while the underlying state has already moved on. + useEffect(() => { + setBuffer(initial) + }, [initial]) + const handleChange = useCallback( (next: string) => { setBuffer(next) @@ -450,6 +458,13 @@ function CodeLeafEditor({mode, value, editable, onChange}: CodeLeafEditorProps) const initial = useMemo(() => valueToDisplay(value, mode), [value, mode]) const [buffer, setBuffer] = useState(initial) + // Resync the local buffer when `value` changes externally — same + // reasoning as TextLeafEditor above. Mode switches (json ↔ yaml) also + // change `initial`, so this covers the user toggling view modes too. + useEffect(() => { + setBuffer(initial) + }, [initial]) + const handleChange = useCallback( (next: string) => { setBuffer(next) diff --git a/web/packages/agenta-shared/src/utils/templateVariable.ts b/web/packages/agenta-shared/src/utils/templateVariable.ts index 209e27d582..370795c90f 100644 --- a/web/packages/agenta-shared/src/utils/templateVariable.ts +++ b/web/packages/agenta-shared/src/utils/templateVariable.ts @@ -118,8 +118,16 @@ export function validateTemplateVariable(expr: string): TemplateVariableValidati .filter(Boolean) if (tokens.length === 0) { // `{{$}}` (whole context as compact JSON) is valid mustache - // JSONPath. `{{$.}}` or similar empties are caught by the - // hasEmptySegment check above. + // JSONPath. `{{$.}}` (root + trailing dot, no field) reaches + // here too because `hasEmptySegment` only catches DUPLICATED + // separators (`..`, `//`), not a lone trailing one — so reject + // it explicitly. Only the bare `$` form is allowed. + if (expr !== "$") { + return { + valid: false, + reason: "JSONPath root has no field after `$`.", + } + } return {valid: true} } // Per the RFC, the JSONPath root can be either an envelope slot diff --git a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx index a324aea403..d06112b518 100644 --- a/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx +++ b/web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx @@ -273,13 +273,25 @@ export function TokenMenuPlugin({tokens, templateFormat = "curly"}: TokenMenuPlu for (const s of provided) push(s.label, {hint: s.hint}) } - // Mine previously-seen flat tokens (everything that doesn't - // start with `$.`) sharing the current dot-prefix. + // Mine previously-seen flat tokens (everything that doesn't start + // with `$.`) sharing the current dot-prefix. + // + // Mustache section openers in the document show up here too — the + // token body is `#languages` / `^empty`. Two things matter: + // - In FLAT mode, those tokens should be skipped entirely (they + // aren't flat-style names and shouldn't pollute the list). + // - In SECTION / inverted-section mode, strip the leading `#` / + // `^` before comparing prefixes and extracting the next + // segment. Otherwise selecting one would re-prefix in + // `push()` and produce `{{##languages}}` / `{{^^empty}}`. const flatPrefix = prefix.length > 0 ? `${prefix.join(".")}.` : "" for (const token of dynamicallyReadingTokens) { if (!token || token.startsWith("$.") || token === "$") continue - if (flatPrefix && !token.startsWith(flatPrefix)) continue - const rest = flatPrefix ? token.slice(flatPrefix.length) : token + const hasSectionMarker = token.startsWith("#") || token.startsWith("^") + if (hasSectionMarker && mode === "flat") continue + const normalized = hasSectionMarker ? token.slice(1) : token + if (flatPrefix && !normalized.startsWith(flatPrefix)) continue + const rest = flatPrefix ? normalized.slice(flatPrefix.length) : normalized const nextSeg = rest.split(/[.[\]'"]/).filter(Boolean)[0] if (!nextSeg) continue push(nextSeg, {hint: "seen"}) From 4ba4738633c0ec7855bc1bf612703eca97d351fa Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 27 May 2026 19:19:13 +0200 Subject: [PATCH 41/41] fix(frontend): thread active templateFormat into chat-mode variable inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finding (#3 of 6) — `ChatMessageList` was hardcoded to `templateFormat="curly"` inside `VariableCard`. So when the prompt was authored in mustache and a variable's value was chat-shaped, the inner per-message editors would tokenize `{{...}}` as curly — mustache sections (`{{#name}}`) and close tags (`{{/name}}`) inside a chat message would render in the wrong color, autocomplete would offer the wrong mode, etc. Thread the active format through the chain: SingleLayout / ComparisonLayout └─ read `parameters.prompt.template_format` (legacy nested) or `parameters.template_format` (canonical root) from `workflowMolecule.selectors.data(entityId)`. Fall back to `"curly"` when no value is stored. └─ pass `templateFormat={promptTemplateFormat}` to PlaygroundInputsBodyHost └─ forwards to PlaygroundInputsBody └─ forwards to VariableCard └─ forwards to CardBody └─ uses it on ChatMessageList in chat mode The Host doesn't resolve `templateFormat` itself because comparison layouts may render multiple variants with different declared formats — picking one is the caller's call. SingleLayout uses the row's single `entityId`; ComparisonLayout uses the structural root node (first depth-0 node) as the canonical primary. All hops default the prop to `"curly"` so consumers that don't pass it preserve today's behavior. --- .../assets/ExecutionRow/ComparisonLayout.tsx | 31 +++++++++++++++++++ .../assets/ExecutionRow/SingleLayout.tsx | 21 +++++++++++++ .../PlaygroundInputsBody.tsx | 5 +++ .../PlaygroundInputsBodyHost.tsx | 8 +++++ .../PlaygroundInputsBody/VariableCard.tsx | 22 +++++++++++-- 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx index a879042df9..3ebe00a880 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useMemo} from "react" import type {PlaygroundNode} from "@agenta/entities/runnable" +import {workflowMolecule} from "@agenta/entities/workflow" import {executionItemController, playgroundController} from "@agenta/playground" import {getEvaluatorVerdictFromOutput} from "@agenta/playground/utils" import type {DropdownButtonOption, DropdownButtonOptionStatus} from "@agenta/ui/components" @@ -122,6 +123,35 @@ const ComparisonLayout = ({ // `VariableControlAdapter` loop. Off by default; OSS opts in. const useNewInputsBody = useAtomValue(useNewPlaygroundInputsBodyAtom) + // Resolve the active prompt template_format from the structural root + // (the first depth-0 node). In comparison view multiple variants may + // declare different formats — we pick the structural root's value as + // the canonical one for tokenization inside chat-mode variable inputs. + // The trade-off is acceptable: the same testcase data feeds all + // variants, and the chat editor's `templateFormat` only affects how + // `{{...}}` segments TOKENIZE inside message content (it doesn't + // change rendering at runtime). + const primaryEntityIdForTemplateFormat = structuralRootNode?.entityId ?? entityId ?? "" + const primaryWorkflowData = useAtomValue( + useMemo( + () => workflowMolecule.selectors.data(primaryEntityIdForTemplateFormat), + [primaryEntityIdForTemplateFormat], + ), + ) + const promptTemplateFormat = useMemo<"mustache" | "curly" | "fstring" | "jinja2">(() => { + const params = primaryWorkflowData?.data?.parameters as Record | undefined + const prompt = params?.prompt as Record | undefined + const raw = + (prompt?.template_format as string | undefined) ?? + (prompt?.templateFormat as string | undefined) ?? + (params?.template_format as string | undefined) ?? + (params?.templateFormat as string | undefined) + if (raw === "mustache") return "mustache" + if (raw === "jinja2" || raw === "jinja") return "jinja2" + if (raw === "fstring") return "fstring" + return "curly" + }, [primaryWorkflowData?.data?.parameters]) + const {getNodeLabel} = usePlaygroundNodeLabels(nodes) const mapStatuses = useCallback( @@ -299,6 +329,7 @@ const ComparisonLayout = ({ rowId={rowId} downstreamKey={downstreamKey} editable={!disabled} + templateFormat={promptTemplateFormat} /> ) : ( diff --git a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx index a56feedda6..4826bcf081 100644 --- a/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx +++ b/web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx @@ -622,6 +622,25 @@ const SingleView = ({ useMemo(() => workflowMolecule.selectors.isDirty(entityId), [entityId]), ) + // Resolve the active prompt template_format from the primary entity's + // parameters. Passed into `PlaygroundInputsBodyHost` so chat-mode + // variable inputs tokenize `{{...}}` with the right rules (mustache + // sections, jinja2, etc). Falls back to `"curly"` to match the + // editor stack's default when no value is stored yet. + const promptTemplateFormat = useMemo<"mustache" | "curly" | "fstring" | "jinja2">(() => { + const params = primaryWorkflowData?.data?.parameters as Record | undefined + const prompt = params?.prompt as Record | undefined + const raw = + (prompt?.template_format as string | undefined) ?? + (prompt?.templateFormat as string | undefined) ?? + (params?.template_format as string | undefined) ?? + (params?.templateFormat as string | undefined) + if (raw === "mustache") return "mustache" + if (raw === "jinja2" || raw === "jinja") return "jinja2" + if (raw === "fstring") return "fstring" + return "curly" + }, [primaryWorkflowData?.data?.parameters]) + const executionRowIds = useAtomValue( executionItemController.selectors.executionRowIds, ) as string[] @@ -913,6 +932,7 @@ const SingleView = ({ downstreamKey={downstreamKey} editable={!isWaitingForVariableControls} sections={groupedSections} + templateFormat={promptTemplateFormat} /> ) } @@ -921,6 +941,7 @@ const SingleView = ({ rowId={rowId} downstreamKey={downstreamKey} editable={!isWaitingForVariableControls} + templateFormat={promptTemplateFormat} /> ) } diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx index f1ce24c435..9d2641325e 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx @@ -121,6 +121,9 @@ export interface PlaygroundInputsBodyProps { /** Optional. When `unreferencedColumns` is shown and the footer is * expanded, gate edits to those rows. Defaults to read-only. */ unreferencedEditable?: boolean + /** Active prompt template format. Forwarded to every `VariableCard` + * so chat-mode rendering tokenizes the right `{{...}}` syntax. */ + templateFormat?: "mustache" | "curly" | "fstring" | "jinja2" } export function PlaygroundInputsBody({ @@ -134,6 +137,7 @@ export function PlaygroundInputsBody({ onViewModeChange, unreferencedEditable = false, connectedSourceName, + templateFormat, }: PlaygroundInputsBodyProps) { // `sections` takes precedence over the flat `inputs` list. We still need // to look up by name to route draft edits, so unify the membership @@ -167,6 +171,7 @@ export function PlaygroundInputsBody({ onValueChange={handleValueChange} onViewModeChange={onViewModeChange} connectedSourceName={connectedSourceName} + templateFormat={templateFormat} /> ) diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx index 052d8e646a..d870d1abe3 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx @@ -49,6 +49,12 @@ export interface PlaygroundInputsBodyHostProps { ariaLabel: string variableNames: string[] }[] + /** Active prompt template format. Forwarded to every variable card so + * chat-mode rendering tokenizes the right `{{...}}` syntax. The + * caller resolves this from the primary entity's prompt config — the + * host doesn't read it itself because it has no single canonical + * entity in comparison layouts (multiple variants may differ). */ + templateFormat?: "mustache" | "curly" | "fstring" | "jinja2" } export function PlaygroundInputsBodyHost({ @@ -56,6 +62,7 @@ export function PlaygroundInputsBodyHost({ downstreamKey, editable, sections, + templateFormat, }: PlaygroundInputsBodyHostProps) { const visibility = useAtomValue( useMemo( @@ -183,6 +190,7 @@ export function PlaygroundInputsBodyHost({ // reducer — it creates the new column on first set. onAddDraftColumn={handleValueChange} connectedSourceName={connectedSourceName} + templateFormat={templateFormat} /> ) } diff --git a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx index aa6217e6e6..2b74c6718b 100644 --- a/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx +++ b/web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx @@ -92,6 +92,11 @@ interface VariableCardProps { * card in the inputs body — the host either passes a name for all * cards or none. */ connectedSourceName?: string | null + /** Active prompt template format. When a value renders in chat mode, + * the inner `ChatMessageList`'s editors use this to tokenize + * `{{...}}` segments inside message content. Defaults to `"curly"` + * to match the rest of the editor stack's defaults. */ + templateFormat?: "mustache" | "curly" | "fstring" | "jinja2" /** Whether the card is editable (vs read-only). */ editable: boolean /** Writes the new value to the testcase / draft store. NATIVE value. */ @@ -113,6 +118,7 @@ export function VariableCard({ expectedType, expectedSchema, connectedSourceName, + templateFormat = "curly", editable, onValueChange, onViewModeChange, @@ -254,6 +260,7 @@ export function VariableCard({ seedShape={seedShape} editable={editable} onChange={handleValueChange} + templateFormat={templateFormat} />
@@ -272,9 +279,20 @@ interface CardBodyProps { seedShape?: unknown editable: boolean onChange: (next: unknown) => void + /** Active prompt template format. Forwarded to `ChatMessageList` when + * the value renders in chat mode so its inner editors tokenize the + * right `{{...}}` syntax. */ + templateFormat?: "mustache" | "curly" | "fstring" | "jinja2" } -function CardBody({mode, value, seedShape, editable, onChange}: CardBodyProps): ReactNode { +function CardBody({ + mode, + value, + seedShape, + editable, + onChange, + templateFormat = "curly", +}: CardBodyProps): ReactNode { const originalType = useMemo(() => inferLogicalType(value), [value]) // For structured modes (form / json / yaml), prefer the schema-derived @@ -336,7 +354,7 @@ function CardBody({mode, value, seedShape, editable, onChange}: CardBodyProps): onChange={(next) => onChange(next)} disabled={!editable} enableTokens - templateFormat="curly" + templateFormat={templateFormat} allowFileUpload={false} /> )