[FE feat] Mustache support - #4465
Merged
Merged
Conversation
Resolve the PR #4393 escape threads (WPB3-014) and the frontend JSDoc thread (WPB3-017): - escape-analysis.md: standalone analysis of literal-{{ escaping. Probes our three engines and real langchain_core 1.2.7; confirms no backslash escape exists in mystace or langchain (delimiter swap / {% raw %} are the only mechanisms). Tables verified empirically. Records the decision (Option 3: document now, defer a \{{ escape). - _mustache-templates.mdx: add an "Emitting literal {{ }}" section. - runnable/utils.ts: extractTemplateVariables JSDoc now lists mustache (shares the {{...}} extraction path); inline comment updated. - findings.md: WPB3-014 and WPB3-017 moved to Closed; all findings resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses three PR #4393 review findings (WPB3-018/019/020): - chatPrompts.ts: extractVariablesFromText missed mustache/curly/jinja2 tags with inner whitespace ({{ name }}). Mustache treats {{ name }} and {{name}} as equivalent, so the {{ }} patterns now allow optional spaces. - TokenPlugin.tsx: the default-branch comment overstated coverage by claiming an "fstring fallback"; the {{ }} regexes do not match fstring's {...} placeholders. Comment corrected to state reality. - types.py: PromptTemplate.template_format defaults to `curly`, but the field description called mustache the default. Reworded so the model default (curly, legacy compat) is distinct from the mustache default that app-creation flows/interfaces set explicitly. Tests: whitespace token-extraction cases added to chatPromptsMustache.test.ts (via the public extractPromptTemplateContext). entity-ui vitest 13 passed; @agenta/shared + @agenta/ui types:check clean; entity-ui lint clean; ruff format + check clean on types.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses two PR #4393 review findings (WPB3-021/022): - _mustache-templates.mdx: the value-coercion table claimed dict/list render as compact JSON with "no extra whitespace" (e.g. {"x": 1}). The renderer uses json.dumps(ensure_ascii=False) with default separators, so the real output is {"x": 1, "y": 2} (spaces after : and ,). Reworded the row to match; renderer unchanged (curly-matching behavior is intended). - Parent RFC + README: the {{$...}} description still used the superseded "pre-rendered as JSONPath ... then the resulting template is rendered" framing, implying JSONPath results are fed back through the engine. WPB3-010 fixed only the wp-b3 doc set; this extends the same correction to the parent docs. Reworded every occurrence (rfc.md / README.md) to shield -> render -> substitute-last (inert data, never re-parsed); also tightened wp-b3 rfc.md. Verification: render-helper + structured-rendering suites 185 passed (covers all four modes incl. jinja2's shared-JSONPath path); ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make explicit in summary.md and web-handoff.md that adding mustache touched
the other renderers via a shared {{$...}} JSONPath helper:
- curly: functionally equivalent (output unchanged; now the reference behavior).
- jinja2: refactored onto the shared helper, behavior preserved.
- fstring: untouched.
- error-contract change spans all formats but is only newly observable for
mustache/jinja2: the "Unreplaced variables in <format> template" message now
interpolates the real format instead of the hardcoded "curly". curly wording
is identical to before; fstring never raises this error so the branch is
dormant for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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).
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.
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.
…body
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.
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.
Missed the package.json hunk in the TemplateFormatPicker commit (cace2ec). Adds the "./template-format" subpath so consumers can import {TemplateFormatPicker} from "@agenta/entity-ui/template-format".
…flagged)
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.
…o fe-feat/mustache-support # Conflicts: # api/uv.lock # services/uv.lock
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
…orts up) 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.
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.
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.
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.
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.
Phase 2c of `docs/designs/mustache-section-support.md`. `{{#repos}}{{name}}
{{stars}}{{/repos}}` semantically iterates an array of objects, but the
old `groupTemplateVariables` type-inference rule classified `repos` as
`"object"` because sub-paths were present — overriding the iteration
intent the section opener signalled.
Updated the priority so section opener wins over sub-paths:
- section opener → `"array"` (regardless of sub-paths). When sub-paths
are also present, they describe the ROW (items) shape.
- sub-paths without section opener → `"object"` (unchanged).
- neither → `"string"`.
Schema producer (`molecule.ts`, two call sites) updated to emit the
matching JSON Schema:
- type === "array" + subPaths → `{type: "array", items: <object-with-properties>}`
- type === "array" + no subPaths → `{type: "array"}` (unchanged)
- type === "object" + subPaths → `{type: "object", properties, _pathHints}` (unchanged)
So `repos` referenced as `{{#repos}}{{name}}{{stars}}{{description}}
{{#contributors}}{{name}}{{/contributors}}{{/repos}}` now produces:
type: "array",
schema: {
type: "array",
items: {
type: "object",
properties: {name, stars, description, contributors},
_pathHints: ["name", "stars", "description", "contributors", "contributors.name"]
}
}
Single-object templates still render at runtime — mustache treats a
non-array truthy value as a one-element iteration — so the choice is
about the FE default, not template correctness.
Tests in `port-helpers.test.ts`:
- Replaced the obsolete "keeps sub-pathed names as object even when
in sectionOpeners" assertion with three new cases:
- Sub-pathed section opener → `array` with subPaths (the RFC
Phase 2c case).
- Nested section openers → `array` with sub-paths spanning multiple
depths (e.g. `users.name`).
- Non-section names with sub-paths → `object` (existing intent).
- All 59 port-helpers tests pass; 518 entities tests pass overall.
Out of scope here (handled by the next commit, Phase 2d):
- Form view's array-of-objects editor + `+ Add row` affordance.
- Default-view selection: array-of-objects → Form (instead of JSON).
…ports
Phase 2d of `docs/designs/mustache-section-support.md`. Pairs with the
Phase 2c schema-inference change so `{{#repos}}{{name}}{{/repos}}` ports
get a usable Form view (array of rows with editable nested objects)
rather than the indexed-record fallback the old code wrapped them into.
Changes:
1. `FormView` accepts arrays at the root.
Signature widened to `value: Record<string, unknown> | unknown[]`.
When `value` is an array, FormView renders the new `ArrayBody`
component directly; when it's an object, the existing `ObjectRows`
path. The nested array case (a property of `value` is an array)
reuses the same `ArrayBody` via FieldBody.
2. New `ArrayBody` component.
Renders each row as a `FormField` labelled by index, with a per-row
remove button (`MinusCircle` icon, `text` button) and a single
`+ Add row` dashed button at the bottom (`Plus` icon). New rows are
created via `structuredClone(arrayItemTemplate)` — empty objects
matching the items schema for array-of-objects ports, or `null`
when no template is provided (arrays of primitives).
3. `VariableCard.CardBody` no longer wraps arrays into indexed records.
The previous workaround built `{"0": item, "1": item, ...}` to feed
FormView's object-only signature, then unpacked the resulting record
back into an array on write-back. Both halves go away now that
FormView speaks arrays. CardBody computes the row template from
`expectedSchema.items` via `buildEmptyShapeFromSchema` and threads
it through.
Also adds a migration coercion: if `expectedSchema.type === "array"`
but the runtime value is `{}` (the auto-seeded shape from
pre-Phase-2c testcases), coerce to `[]` so the user sees the form-
array editor instead of `(empty object)`. Non-empty objects pass
through to preserve any real data on ports that historically held
objects.
4. Default view for array-of-objects → Form.
`getDefaultViewForExpectedType` now takes an optional
`expectedSchema` and uses a small `isArrayOfObjectsSchema` helper
to detect the array-of-objects case (`type: "array"` with
`items.properties` or `items._pathHints`). Matches when the schema
describes a row shape; falls back to JSON for arrays of primitives
or unknown-item arrays. `PlaygroundInputsBody` passes
`variable.expectedSchema` through to the helper.
5. `splitInputsVisibility` narrowed to empty CONTAINERS only.
The previous broad "any empty value → draft" rule (`null` /
`undefined` / `""` / `{}` / `[]`) conflicted with the existing
`playground-inputs-visibility.test.ts` contract that primitives
stay authored when the key is present. Narrowed to just empty
containers (`{}` / `[]`) — the actual auto-seed case for object /
array ports. Primitives keep the old `in`-style behaviour. Updated
my own visibility tests to match (3 primitive tests moved from
"draft" → "authored"); the original `null` / `undefined` tests in
the entities package now pass cleanly.
Acceptance against Mahmoud's prompt (`{{#repos}}{{name}}{{stars}}
{{description}}{{#contributors}}{{name}}{{/contributors}}{{/repos}}`):
- `repos` card opens in Form view by default (array-of-objects).
- Empty `repos` shows just an `+ Add row` button.
- Clicking it appends a row with empty `name`, `stars`, `description`,
`contributors` fields (per the items schema).
- Each row has a `×` remove button next to the `0` / `1` / … label.
Verified: `@agenta/entity-ui` + `@agenta/playground-ui` + `@agenta/
entities` + `@agenta/playground` tsc clean; 573 tests across the
four packages pass; prettier clean.
Limitations (callable as Phase 2e follow-ups):
- Nested section openers inside an outer section still render as
objects inside the row, not arrays-of-objects. `repos.contributors`
is sub-paths of `repos`'s row, so it inherits the items schema's
object treatment rather than getting its own array shape.
Addressing this requires tracking section-opener IDENTITY at the
parser AST level and propagating through `groupTemplateVariables`,
out of scope for the minimal Phase 2d.
PR #4465 ended up landing Phase 1 AND Phase 2a-2d (the original plan had Phase 2 going to a separate follow-up PR off main). The doc was still framed as "Phase 1 = this PR, Phase 2 = future" which confused the open vs deferred scope. Updates: - Header status switched from "ready to implement Phase 1" to "Phase 1 + 2a-2d shipped; 2e + nested-opener inference deferred". - Added a "Ship status (PR #4465 commits)" table mapping each phase to its commit hash so reviewers can trace what's in the diff. - Added §2e' "Known limitation: nested section-opener inference" with the data-flow explanation: walker emits dotted paths at every depth fine, but `groupTemplateVariables` flattens them to `subPaths: string[]` and loses section identity. The schema producer then renders nested sections as objects rather than arrays of objects. Runtime is unaffected (mustache iterates whatever the user fills), but the form view's `+ Add row` affordance only surfaces at the top section. Fix scope is a separate PR — two options sketched for the architectural choice (tree vs Set). - Rewrote the bottom "Implementation order" sections from "(Phase 1) + (Phase 2 — new PR, after #4465 merges)" to: - "Implementation history (PR #4465)" — what landed where. - "Deferred" — 2e + nested-opener. - "Out of scope" — lambdas etc. - "QA validation" — what Mahmoud's prompt produces today. No source changes; doc only.
…tion openers
Closes the Phase 2 limitation called out in `docs/designs/mustache-
section-support.md` §2e' — `{{#repos}}{{#contributors}}{{name}}
{{/contributors}}{{/repos}}` now produces the right items-of-items shape
in the form view instead of treating `contributors` as a nested object.
Threads section-opener identity through the chain:
1. `extractMustacheSectionOpeners` (utils.ts) rewritten on the parser.
Walks the AST and emits a DOTTED PATH for every section opener,
joined against the enclosing-section stack. So for the prompt above,
the set is `{"repos", "repos.contributors"}` instead of the old
`{"repos", "contributors"}` (which couldn't distinguish a top-level
`contributors` from a nested one).
2. `groupTemplateVariables` (portHelpers.ts) records nested sections
per group.
For each opener path, `parseTemplateExpression` splits into
`{envelope, key, subPath}`. Top-level openers (no subPath) still
feed `sectionOpenerIds` for the group's type inference. Nested
ones go into a new `nestedSectionsByGroup` map keyed by the group
ID, with values being sub-paths relative to the group root.
Output `GroupedTemplateVariable` gains an optional `sectionSubPaths:
string[]` field — subset of `subPaths` that are themselves section
openers within the group.
3. `buildSubPathSchema` (molecule.ts) made recursive.
New signature: `(subPaths, sectionSubPaths?, prefix?)`. Groups paths
by first segment, then for each child:
- If the child's full path is in `sectionSubPaths` AND it has
further sub-paths → emit `{type: "array", items: <recurse>}`.
- If in sectionSubPaths with no sub-paths → `{type: "array"}`.
- If has sub-paths but isn't a section → recurse as `object`.
- Leaf → `{type: "string"}`.
`_pathHints` is only emitted at the root level, and only when there
are no array-typed children — the flat-hint format can't represent
array nesting, and emitting it would shadow the precise nested
shape encoded in properties.
Both schema-producer call sites in `molecule.ts` updated to pass
`group.sectionSubPaths` through, and the second call site also now
properly wraps `array`-typed groups (it was previously emitting an
object schema for array groups — a Phase 2c oversight, now consistent
between the two branches).
4. `buildEmptyShapeFromSchema` (viewTypes.ts) prefers `properties` when
any nested property is `type: "array"`.
New `hasArrayProperty(properties)` helper walks the property tree
looking for arrays. When found, the function skips the `_pathHints`
branch (which would flatten arrays into objects) and runs the
recursive properties walk, which honours array shapes via the
existing `s.type === "array" → []` rule.
5. Tests.
`extract-template-variables.test.ts` — 4 new cases for
`extractMustacheSectionOpeners` covering nested paths, three-deep
nesting, inverted-section mixing, and same-depth dedup.
`port-helpers.test.ts` — 3 new cases for `groupTemplateVariables`'s
nested handling: records `sectionSubPaths` correctly under the
group root, captures nested paths at every depth (up to three),
and omits the field when no nested sections exist. Updated the
existing "infers array for nested section openers too" assertion
to use the new dotted-path hint format.
Effect on Mahmoud's QA prompt (`{{#repos}}{{#contributors}}…`):
Before:
items: { type: 'object', properties: { name, contributors },
_pathHints: ['name', 'contributors', 'contributors.name'] }
→ row template: { name: '', contributors: { name: '' } }
^^^^^^^^^^^^^^^^^^^^^^^ wrong: should be array
After:
items: { type: 'object', properties: {
name: { type: 'string' },
contributors: { type: 'array', items: { type: 'object', properties:
{ name: { type: 'string' } } } }
} }
→ row template: { name: '', contributors: [] }
^^^^^^^^^^^^^ correct: empty array of objects
Form view now renders `contributors` as another array editor with `+
Add row` inside each `repos` row, matching the iteration semantics
the user authored.
Verified across the package suite: 524 entities tests + 55 playground
tests + tsc clean for `@agenta/entities`, `@agenta/entity-ui`,
`@agenta/playground`, `@agenta/playground-ui`.
…and non-mustache regressions
Two concerns raised after the nested-section-opener inference shipped:
1. While the user TYPES (not pastes) a prompt, do extraction +
tokenization stay sane at every intermediate stage? Editor
autoclose pairs `{{` with `}}`, so tokens are syntactically
complete throughout typing (e.g. `{{#r}}`, `{{#re}}`, `{{#rep}}`)
— but partial / empty / mismatched-close states must not crash
the walker or pollute variable extraction with junk.
2. Do the new implementations regress curly / jinja2 / fstring? The
widened mustache-marker exclusion (`$<=` added for spec block /
parent / delimiter sigils) and the recursive `buildSubPathSchema`
shape change need explicit coverage.
Bug fix:
The walker pushed every section name onto the path stack — including
empty strings when the user has typed `{{#}}` (autoclose state with
no name yet) but hasn't typed the section name yet. A subsequent
variable would then join as `".name"` (leading dot). Walker now
skips empty section names on both `onEnter` (no push, no emit) and
`onExit` (no pop, matching the skip). Same fix applied to
`extractMustacheSectionOpeners` so dotted-path emission stays
clean too.
Tests added — 33 new cases across two suites:
Typing-state (mustache, models real autoclose flow):
- 19 cases stepping through `{{#repos}}{{name}}{{/repos}}{{country.a}}`
one character at a time. Walker output verified at every stage.
- Unclosed nested sections (`{{#repos}}{{#contributors}}` mid-typing)
→ parser reports unbalanced but walker still emits sane paths.
- Empty section names (`{{#}}{{name}}{{/}}`) don't leak leading
dots into inner variable extraction.
- In-progress section names (`{{#r}}`) emit the partial name —
no regression from pre-Phase 2 behaviour.
- Partial / mismatched close tags (`{{/r}}` when open was
`{{#repos}}`) don't crash the walker.
Non-mustache regressions:
- Curly: plain variables, literal dotted names (`{{user.name}}` stays
as `user.name`, NOT split into nested), all mustache-style
structural tags excluded (`{{#x}}`, `{{$slot}}`, `{{<base}}`,
`{{=<% %>=}}`).
- Jinja2: dotted attribute access, mustache tags excluded.
- Fstring: single-brace `{name}` extracted, `{{x}}` (escaped
literal braces) NOT extracted.
- Unclosed mid-typing inputs don't crash any format.
All 557 entities + 55 playground tests pass; tsc + prettier clean.
… their own items shape
Arda screenshot 2026-06-02: in the test prompt's `{{#repos}}{{#contributors}}
{{name}}{{/contributors}}{{/repos}}` rendering, each `contributors` row
exposed `name`, `stars`, `description`, AND another `contributors`
field — the same shape as the outer `repos` row, not the inner
`{name: string}` shape mustache iteration implies. Bug isolated to
Phase 2d's row-template plumbing.
Root cause: `arrayItemTemplate` was passed as a SINGLE prop from
VariableCard down through every nested level of FormView (ObjectRows
→ FormField → FieldBody → nested ArrayBody). The template was computed
once at the variable-card boundary from `expectedSchema.items` — the
outer items shape — and reused at every depth. When the user clicked
`+ Add row` on the INNER `contributors` array, it pushed a clone of
the OUTER row template, producing the screenshot's confused fields.
Fix: replace the `arrayItemTemplate` prop with a `schema` prop that
descends through every FormView level:
- `FormView` accepts `schema?: unknown`.
- `ObjectRows` looks up `schema.properties[key]` for each child and
passes that slice down.
- `FormField`/`FieldBody` thread `schema` through unchanged.
- `ArrayBody` derives ITS OWN row template from `schema.items` LOCALLY
(`buildEmptyShapeFromSchema(schema.items)`), and passes
`schema.items` to each row's `FormField` so descent continues with
the per-row sub-schema.
Effect on the user's prompt: a `repos[0].contributors[0]` row now
shows just `{name: ""}` — the local items shape — and clicking
`+ Add row` inside the inner contributors pushes another `{name: ""}`
rather than a fresh `{name, stars, description, contributors}` from
the outer template.
Three-level cases (e.g. `{{#repos}}{{#contributors}}{{#tags}}{{name}}`)
work too — each ArrayBody pulls its local items schema and computes
the right row at every depth.
VariableCard.CardBody simplified: no more pre-computing
`arrayItemTemplate` at the card boundary; just passes `schema=
{expectedSchema}` to FormView. The migration-coercion (empty `{}` →
`[]` on retyped array ports) stays.
Existing tests unaffected — the entity-ui suite doesn't exercise the
form view's array path directly (it's covered via the playground
integration). 557 entities + 55 playground tests pass; tsc + prettier
clean.
…fields appear on existing rows
Arda QA 2026-06-02: typing a new `{{#test}}{{xyz}}{{/test}}` section
inside `{{#repos}}…{{/repos}}` after the user had already filled in a
row produced a correct schema (the row template gained a `test:
[{xyz}]` entry) but the existing row stayed at its old shape — the
new `test` field didn't appear.
Root cause: `ObjectRows` iterated `Object.entries(obj)` — the VALUE's
keys. Existing rows didn't carry a `test` key, so the new field never
rendered. Newly-added rows (created after the section was typed) did
get the field via `arrayItemTemplate`, but rows from before the edit
silently dropped behind the schema.
Fix: when a schema is available, iterate the SCHEMA's `properties`
keys as the canonical list, then append any value-only keys at the
end (preserves legacy data + user-authored fields outside the
declared schema). For schema-only keys (the existing-row gap), use
`buildEmptyShapeFromSchema(properties[key])` as the displayed empty
default; the user can fill it in and the edit propagates back through
`updateKey` like any other field.
Empty-object hint behaviour kept: when both schema lacks properties
AND value is empty, we still show `(empty object)`.
Verified: 557 entities + 55 playground tests pass; tsc + prettier
clean. The fix is fully recursive — typing a nested section opener
three levels deep (e.g. `{{#repos}}{{#test}}{{#nested}}{{x}}{{/nested}}
{{/test}}{{/repos}}`) surfaces correctly on existing rows at any
depth.
…d right-edges align
Arda QA 2026-06-02: inside an array row (e.g. a `repos[0]` object), the
nested fields' view-type selectors (`Text ▾`) didn't reach the card's
right edge — they were inset by roughly the remove-button width
compared to non-array fields (`user.email`'s selector hit the edge,
`repos[0].name`'s didn't).
Root cause: `ArrayBody` wrapped each row's `FormField` in a flex
container (`arrayRow`) with the remove button as a sibling
(`arrayRowField` = flex:1, button beside it). That made the field's
ENTIRE column — including the nested body below — narrower than the
card by the button + gap. The nested `labelRow`'s `space-between`
then pushed each nested view-type selector to the inset right edge of
`arrayRowField`, not the card edge.
Fix: pass the remove button INTO `FormField` via a new `headerRight`
slot that renders in the row's own label row (far right, after the
view-type selector). The field body now spans the full card width, so
nested selectors align with the card edge — matching non-array fields.
- `FormField` gains `headerRight?: ReactNode`; the labelRow's right
side is now a `labelRight` flex container holding the view-type
selector and/or `headerRight`.
- `ArrayBody` drops the `arrayRow` / `arrayRowField` flex wrapper and
passes the remove button as `headerRight`.
- Removed the now-dead `arrayRow` / `arrayRowField` styles; trimmed
`arrayRowRemove` (no longer needs the top-margin alignment hack).
The ⊖ button stays visually where it was (the `N object` row's right
edge); only the field body's width changed. Works at every nesting
depth — a contributor row inside a repo row inside the top-level
array all align to the same right edge now.
557 entities + 55 playground tests pass; tsc + prettier clean.
The refine-prompt endpoint hits an upstream AI service that isn't trivially runnable locally — "Failed to connect to upstream service" (useRefinePrompt.ts:186). This blocks verifying the apply-doesn't- revert fix (commit 7011ff4) without the real service. Adds an opt-in mock in `aiServicesApi.refinePrompt`: - Gated by `isRefineMockEnabled()` — env `NEXT_PUBLIC_MOCK_REFINE_PROMPT =true` OR `localStorage["agenta:mock-refine-prompt"] = "true"`. Both resolve falsy by default → zero production impact, real endpoint untouched. - When on, returns a synthetic success that prepends a `[refined] ` marker to each message's content (idempotent — won't stack on repeated refines). This makes the revert observable: after "Use refined prompt", the editor should show `[refined] …`; if the old race regressed, the marker would disappear as the editor reverts. - 600ms simulated latency so the apply flow runs against a realistic async boundary. - Magic guidelines for the other branches: "error" → `isError: true`, "noop" → prompt unchanged. Usage: localStorage.setItem("agenta:mock-refine-prompt", "true") // reload page // or: NEXT_PUBLIC_MOCK_REFINE_PROMPT=true (restart dev server) // then open Refine Prompt, type any instruction, Submit, Use refined. Dev aid only — no behaviour change in the shipped path.
…lable in dev The explicit-flag mock (commit 36dd634) still required setting localStorage / env correctly; the real `axios.post` kept throwing "AI services are disabled" when the flag wasn't picked up. Reworked into three modes via `getRefineMockMode()`: - "force" — always mock (env `NEXT_PUBLIC_MOCK_REFINE_PROMPT=true` or `localStorage["agenta:mock-refine-prompt"] = "true"`). - "off" — never mock; surface the real error (`localStorage["agenta:mock-refine-prompt"] = "false"`). - "auto" (DEFAULT) — try the real endpoint; on error, if not in production, fall back to the mock with a console.warn. So with ZERO setup, refine now works locally: the real call fails ("AI services are disabled"), the catch falls back to the mock, and the modal returns a `[refined] `-marked prompt. The revert-fix is testable immediately. Production is unaffected: the real service responds so the catch never fires, and the `NODE_ENV === "production"` guard re-throws even if it somehow did. prettier clean.
…is silent The auto-fallback (ac69b91) caught the axios error and returned the mock, but the Next.js error overlay STILL popped: the shared axios response interceptor calls `globalErrorHandler(error)` for every non-GET failure BEFORE re-throwing, so the overlay fired before our catch ran — even though the rejection was handled. Pass `_ignoreError: true` in the request config. The interceptor checks this early (`if (error.config?._ignoreError) throw error`) and skips `globalErrorHandler`, just re-throwing — so our catch falls back to the mock silently. Same pattern used across the codebase (organization API, eval-run atoms, references) to opt out of the global overlay. Now in dev, with zero setup: refine → real call fails ("AI services are disabled") → interceptor re-throws quietly → our catch returns the `[refined] `-marked mock → modal shows the refined prompt. No overlay. prettier clean.
…tted paths)
[critical] Mahmoud QA 2026-06-03: a mustache prompt `{{country.name}}` +
`{{ab.b}}` saved `input_keys: ["country.name", "ab.b"]`, but the backend
keys the runtime `inputs` dict by TOP-LEVEL names (`country`, `ab`). On
every deployed invoke the SDK rejected it:
`Invalid inputs: Expected ['country.name'] Got ('list') ['ab', 'country']`.
Two paths computed `input_keys` and disagreed:
- Invoke request (requestBodyBuilder): top-level port keys from
`groupTemplateVariables` → correct ("we do the request correctly").
- Commit/save (commit.ts → syncPromptInputKeysInParameters): raw
`extractVariablesFromConfig` → scope-aware DOTTED paths for mustache
→ wrong ("this happens only in saving").
Root cause: `syncPromptInputKeysInConfig` used the raw scope-aware walker
output, which is the right shape for PORT DISCOVERY (nested schema) but
the wrong shape for `input_keys` (must be the top-level keys the inputs
dict uses). `groupTemplateVariables` already encodes the format-aware
collapse — mustache/jinja2 `country.name` → key `country`; curly stays
literal `country.name` (backend literal-key resolver). It's the same
helper the invoke path and input-port discovery use.
Fix: new `computePromptInputKeys` routes the extracted variables through
`groupTemplateVariables` (with the prompt's section openers + resolved
template format), filters to the `inputs` envelope, and returns the
deduped top-level keys. `syncPromptInputKeysInConfig` uses it.
My Phase 2 section work amplified the bug (`{{#repos}}{{name}}` started
adding `repos.name`), but the base `country.name` issue predates it. Both
are fixed by the top-level collapse.
14 unit tests cover the contract: mustache top-level collapse (incl.
sections, nested sections, dedup, JSONPath envelope filtering), curly
literal preservation (no regression), jinja2, and structural behaviour
(ag_config wrapper, no-op identity, non-prompt entries, null inputs).
578 entities tests pass; tsc + prettier clean.
Mahmoud QA 2026-06-03: creating an LLM-as-a-judge showed curly as the
default with curly offered in the picker. New judges should default to
mustache (curly is legacy, hidden for new prompts).
Root cause: the judge's nested prompt was built WITHOUT a
`template_format` (`transformFlatEvaluatorToNested` omitted it), so the
picker fell back to its hardcoded `"curly"` default — and because the
resolved format was curly, `buildTemplateFormatOptions` showed curly via
its "always show the current format" rule.
A second latent bug: `flattenEvaluatorConfiguration` (reverse transform
at commit) dropped `prompt.template_format` entirely — so even if the
picker set a format on a judge, it never persisted.
Three coordinated changes:
1. createEvaluatorFromTemplate (evaluatorUtils.ts): seed
`parameters.template_format = "mustache"` for NEW LLM judges only —
gated on `Array.isArray(prompt_template)` (LLM-based; non-LLM
evaluators have no prompt) AND no catalog-supplied format. Stored at
the flat level.
2. transformFlatEvaluatorToNested: extract the flat `template_format`
and surface it into `prompt.template_format` so the picker reads it.
Pulled out of the `...rest` spread so it doesn't leak to the top
level. Absent → omitted (legacy judges keep the curly fallback, no
spurious dirty).
3. flattenEvaluatorConfiguration: round-trip `prompt.template_format`
back to flat `template_format` on commit — only when present, so
legacy format-less judges don't flip to dirty.
Net effect:
- New judge → mustache default, curly hidden. ✓
- Picker format changes on any judge now persist (previously dropped). ✓
- Legacy judges (no stored format) → unchanged curly fallback, clean
isDirty. ✓
7 unit tests cover nest (surface / omit / no top-level leak), flatten
round-trip (preserve / picker-change / legacy-untouched), and
nest→flatten idempotency.
578 entities tests pass; tsc + prettier clean.
…AI/agenta into fe-feat/mustache-support
bekossy
approved these changes
Jun 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Mustache support
Playground inputs
Others
Testing
Verified locally
Added or updated tests
QA follow-up
Checklist
Contributor Resources