Skip to content

[FE feat] Mustache support#4465

Open
ardaerzin wants to merge 43 commits into
feat/add-mustache-renderingfrom
fe-feat/mustache-support
Open

[FE feat] Mustache support#4465
ardaerzin wants to merge 43 commits into
feat/add-mustache-renderingfrom
fe-feat/mustache-support

Conversation

@ardaerzin
Copy link
Copy Markdown
Contributor

Summary

Mustache support

  • Mustache section openers ({{#name}} / {{^name}}) surface as input ports — extracted, validated, and typed (array when referenced only via a section opener)
  • TokenTypeaheadPlugin fires on {{#… / {{^… for mustache prompts with the same suggestion sources as flat mode
  • JSONPath validator relaxed to accept testcase-spread roots ({{$.profile.name}}) — matches the SDK's dual-access envelope in completion_v0 / chat_v0
  • Editor accepts mustache section close tags ({{/name}})
  • TemplateFormatPicker wired into the playground prompt-config header
  • Mustache-specific features (section openers, prefix stripping, array-type inference) are gated to templateFormat === "mustache". Curly / jinja2 prompts don't silently extract mustache syntax as variables

Playground inputs

  • Replaces the per-variable VariableControlAdapter with one bordered card per variable: name + type chip + action cluster + "View as ▾" dropdown + value editor
  • Visibility rule: referenced variables render as cards; testcase columns the prompt doesn't reference collapse under a "N unused columns hidden…" footer (expandable, editable when expanded)
  • Schema-aware draft defaults: when an object-typed port is referenced via sub-paths, Form mode opens with the expected sub-fields pre-rendered as empty inputs; JSON / YAML modes get the same skeleton seeded into the buffer. Render-only — the testcase value stays untouched until the user actually edits a field
  • Unified action cluster per card: copy-value button + testset-sync indicator
  • View modes are pure representation transforms — they never change the value's type
  • Consistent dropdown ordering across every kind: kind-specific modes first, JSON / YAML always at the bottom
  • Wired into SingleLayout, ComparisonLayout, and the grouped evaluator layout (with section blocks for the inputs / outputs envelopes)

Others

  • valueToDisplay never auto-parses JSON-shaped strings into objects — a string stays a string in every view mode, addressing the recurring QA finding that the FE was mistaking stringified JSONs for objects
  • Execution input builders pass native objects / arrays / numbers straight through to the request body; no implicit stringification anywhere in the completion + chat path

Testing

Verified locally

  • pnpm --filter @agenta/entities exec vitest run → 399 tests pass (template extraction, port grouping, visibility rule, mustache section openers, JSONPath validator, format-gated extraction)
  • pnpm --filter @agenta/entity-ui exec vitest run → 79 tests pass (view-types, formatters, build-empty-shape, dropdown ordering)
  • pnpm --filter @agenta/playground-ui exec tsc --noEmit → clean
  • pnpm --filter @agenta/ui exec tsc --noEmit → clean
  • Lint clean across @agenta/entities, @agenta/entity-ui, @agenta/playground-ui, @agenta/ui
  • Manual playground E2E, checking trace for transformed prompts

Added or updated tests

  • extract-template-variables.test.ts — mustache section openers, mustache vs curly format gating, dedup
  • port-helpers.test.ts — sectionOpeners hint produces array-typed groups; sub-pathed names still object
  • playground-inputs-formatters.test.ts — strings stay strings in JSON / YAML modes
  • view-types.test.ts — schema-aware defaults, expected-type fallback for empty values, buildEmptyShapeFromSchema with _pathHints, consistent option ordering across kinds
  • playground-inputs-visibility.test.ts — referenced vs unreferenced split, system-field filtering

QA follow-up

  • Existing curly configs — confirm nothing breaks
  • Comparison view + evaluator playgrounds — both consume PlaygroundInputsBody now; visual parity with single view, including the section-block layout for evaluator envelopes
  • Testset drawer — the valueToDisplay fix applies there too (shared formatter). Verify chat editor in the drawer no longer renders stringified JSON as a parsed object

Checklist

  • I have included a video or screen recording for UI changes, or marked Demo as N/A
  • Relevant tests pass locally
  • Relevant linting and formatting pass locally
  • I have signed the CLA, or I will sign it when the bot prompts me

Contributor Resources

ardaerzin added 30 commits May 26, 2026 00:55
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.
… testcase-spread roots

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.
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.
…path

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<string, string>` 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<string, unknown>`,
  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<string, unknown>`
  in both ResolveVariableValues callers and BuildRequestBody params.

`transformToRequestBody` already typed `variableValues?: Record<string,
unknown>` 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.
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.
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 `<SectionBlock>` 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.
…ader

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.
Previously the variable extractor skipped every `{{...}}` token whose
inner expression started with `#`, `^`, `/`, `!`, `>`, or was exactly
`.` — meaning `{{#languages}}...{{/languages}}` produced no port at
all, so the playground had no input card for `languages`.

`#name` (section opener) and `^name` (inverted section opener) ARE
variables — `name` still needs a value at render time (the iterable to
loop, or the truthiness to test). Strip the prefix and surface the
base name as a port. `&name` (unescaped variable) gets the same
treatment. Closers / comments / partials / the implicit `.` stay
skipped — they're structural only.

Tests updated to pin the new semantics:
  {{#languages}}{{.}}{{/languages}}  → ["languages"]
  {{^empty}}none{{/empty}}            → ["empty"]
  {{&html}}                           → ["html"]
  {{/languages}}                      → []
  {{! comment }}                      → []
  {{> partial}}                       → []
  {{.}}                               → []
When a referenced variable is still a draft (no value on the testcase
yet), the inputs body had no way to know its shape — it fell through
to `inferLogicalType(undefined) → "null"` and defaulted to a Text
input with a `null` chip. So a `geo` port referenced via
`{{geo.region}}` / `{{geo.coordinates.lat}}` opened as a single-line
text input even though the prompt clearly authored it as an object.

Plumb the declared port type (from `inputPortSchemaMap`) through:

  PlaygroundInputsBodyHost  reads `inputPortSchemaMap[name].type` and
                            adds `expectedType` to each visibility entry
  PlaygroundInputsBody      uses new `getViewOptionsForExpectedType` /
                            `getDefaultViewForExpectedType` helpers that
                            fall back to expectedType when value is empty
  VariableCard              uses expectedType to derive the TypeChip
                            variant (`object` / `array` / `boolean` /
                            `number` / `string`) when the value is empty

A draft `geo` port now opens as Form with an `object` chip; a draft
`languages` opens with the right shape too. Once the user types a
value, the runtime value takes over (chat-shaped arrays still detect
as Chat, etc.) — `expectedType` is a draft-time hint only.

Tests added in entity-ui covering the empty-value fallback for every
expectedType, the runtime-value-wins case (real string beats
`expectedType: "object"`), and the chat-shaped array override.
When a referenced variable is still a draft (no value on the testcase
yet) AND the port schema describes its expected shape, render the
Form / JSON / YAML view pre-populated with that empty skeleton so the
user sees the fields the prompt is actually asking for.

`geo` referenced via `{{geo.region}}` / `{{geo.subregion}}` /
`{{geo.coordinates.lat}}` / `{{geo.coordinates.lng}}` now opens as a
Form with all four sub-fields visible — instead of an empty form
where the user has to know which keys to add.

How it works:

  new helper  buildEmptyShapeFromSchema  reads `_pathHints` (the
                                          original nested sub-paths
                                          preserved by buildSubPathSchema)
                                          to reconstruct nesting that the
                                          flat `properties` would lose.
                                          Returns null for primitives.

  Host         injects `expectedSchema` (`inputPortSchemaMap[name].schema`)
              onto each variable.

  VariableCard computes a `seedShape` once per render — only when the
              value is empty AND the schema yields a shape. Passes it
              into CardBody.

  CardBody     uses `seedShape ?? value` for Form / JSON / YAML modes
              (where structure is meaningful). Text / Markdown / Chat
              modes use the raw value — seeding wouldn't help there.

Render-only: `onChange` only fires on real user edits, so the testcase
value stays untouched until the user actually types into a field. Once
they do, the full shape is persisted (untouched fields persist as `""`,
which mustache renders identically to `undefined`).

Unit tests cover the helper end-to-end:
  - primitive schemas → null
  - flat properties → flat empty object
  - nested properties → recursive empty object
  - `_pathHints` preferred over flat properties (the playground case)
  - empty `_pathHints` falls through to properties
  - non-string `_pathHints` entries are skipped (defensive)
The "View as ▾" dropdown previously rendered with a custom antd
Dropdown trigger ("View as Text ▾"), a "SELECT HOW TO VIEW" section
header, and a "default" hint pill on the selected row. That was
visually inconsistent with the other small dropdowns in the playground
(the prompt config view-mode picker is a borderless `antd Select` with
just option labels).

Replace the custom Dropdown with a plain borderless `antd Select` —
same visual treatment as the surrounding playground UI. The trigger
now just shows the current mode (e.g. "Text", "Form"); the menu is
just the options list with the selected one highlighted. Same
function, less chrome.

Props are backward compatible — `value`, `options`, `onChange`,
`disabled` all unchanged. Added optional `variant` (default
`"borderless"`) and `className` for surfaces that need an outlined
chip or layout overrides.
…orts

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<string> 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<string>}` 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).
`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).
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.
…down

`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.
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 `<Tag>` 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.
…abels

Previous fix (89f73b3) 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.
ardaerzin added 10 commits May 27, 2026 12:20
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.
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.
Last commit (52967ae) 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.
`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"
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).
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).
…display)

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.
…ield)

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.
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.
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).
@vercel
Copy link
Copy Markdown

vercel Bot commented May 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment May 27, 2026 5:20pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description comprehensively covers the PR's objectives including mustache support, playground input UX changes, native JSON preservation, testing, and a clear checklist.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The PR title "[FE feat] Mustache support" directly corresponds to the main objective of adding Mustache template support and native JSON preservation in the playground UI, as detailed in the PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fe-feat/mustache-support

@ardaerzin ardaerzin marked this pull request as ready for review May 27, 2026 15:43
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label May 27, 2026
@dosubot dosubot Bot added enhancement New feature or request Frontend labels May 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Railway Preview Environment

Preview URL https://gateway-production-b7d3.up.railway.app/w
Project agenta-oss-pr-4465
Image tag pr-4465-61dba77
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-05-27T17:46:32.107Z

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx (1)

264-285: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize seen section tokens before feeding them back into push().

This loop now serves flat + section modes, but it still mines raw token bodies like #languages and ^empty. In section mode that gets prefixed a second time, so selecting a previously-seen section opener inserts {{##languages}} / {{^^empty}}. It also lets section openers leak into plain flat suggestions. Strip the leading control character before extracting segments, and skip # / ^ tokens when mode === "flat".

Suggested fix
-        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 flatPrefix = prefix.length > 0 ? `${prefix.join(".")}.` : ""
+        for (const token of dynamicallyReadingTokens) {
+            if (!token || token.startsWith("$.") || token === "$") continue
+            const normalizedToken =
+                token.startsWith("#") || token.startsWith("^") ? token.slice(1) : token
+            if (mode === "flat" && normalizedToken !== token) continue
+            if (flatPrefix && !normalizedToken.startsWith(flatPrefix)) continue
+            const rest = flatPrefix ? normalizedToken.slice(flatPrefix.length) : normalizedToken
             const nextSeg = rest.split(/[.[\]'"]/).filter(Boolean)[0]
             if (!nextSeg) continue
             push(nextSeg, {hint: "seen"})
         }
web/packages/agenta-entities/src/runnable/portHelpers.ts (1)

304-338: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Scope sectionOpeners to the grouped variable id.

groups are keyed by ${envelope}.${key}, but the array inference only checks sectionOpeners?.has(key). If the template contains both inputs.languages and outputs.languages, a section opener for one will coerce the other group to "array" too. Please key the opener signal with the same ${envelope}.${key} identity used by groups.

web/packages/agenta-entities/src/runnable/utils.ts (1)

1-10: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix Prettier formatting failure.

The CI pipeline reports a Prettier formatting check failure. Run pnpm lint-fix in the web folder to auto-fix formatting issues. As per coding guidelines: "Run pnpm lint-fix in the web folder after making changes to the frontend".

🧹 Nitpick comments (2)
web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx (1)

61-63: ⚡ Quick win

Replace inline style objects with Tailwind utility classes.

This component currently uses CSS-in-JS (style={...} and styles object), which diverges from the frontend styling standard in this repo.

Proposed refactor
-            <Button type="text" size="small" disabled={disabled} style={styles.trigger}>
-                <span style={styles.triggerValue}>{VIEW_LABELS[value]}</span>
-                <CaretUpDown size={12} style={styles.triggerCaret} />
+            <Button
+                type="text"
+                size="small"
+                disabled={disabled}
+                className="inline-flex h-6 items-center gap-1 rounded px-2 text-xs"
+            >
+                <span className="font-semibold text-[`#051729`]">{VIEW_LABELS[value]}</span>
+                <CaretUpDown size={12} className="mt-px opacity-65" />
             </Button>
         </Dropdown>
     )
 }
-
-const styles = {
-    trigger: {
-        display: "inline-flex",
-        alignItems: "center",
-        gap: 4,
-        padding: "0 8px",
-        height: 24,
-        borderRadius: 4,
-        fontSize: 12,
-    },
-    triggerValue: {color: "`#051729`", fontWeight: 600},
-    triggerCaret: {marginTop: 1, opacity: 0.65},
-}

As per coding guidelines, "Always prefer Tailwind utility classes over CSS-in-JS for styling".

Also applies to: 69-81

web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx (1)

169-181: ⚡ Quick win

Use async/await for the clipboard write.

This is a simple one-shot async path, so the .then() chain just diverges from the repo convention and makes the handler harder to read than a local try/catch.

Suggested fix
-    const handleCopy = useCallback(() => {
+    const handleCopy = useCallback(async () => {
         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}),
-        )
+        try {
+            await navigator.clipboard.writeText(text)
+            message.success({content: "Copied", duration: 1.5})
+        } catch {
+            message.error({content: "Copy failed", duration: 2})
+        }
     }, [value])

As per coding guidelines, "Always use async/await for promises instead of .then() chains".


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8160798c-e634-4949-a170-bdbe6ad38b47

📥 Commits

Reviewing files that changed from the base of the PR and between 728f415 and b83dee2.

📒 Files selected for processing (41)
  • docs/design/prompt-runtime-unification/wp-f-playground-mustache/test-plan.md
  • docs/design/prompt-runtime-unification/wp-f-playground-mustache/testset.json
  • web/packages/agenta-entities/src/runnable/index.ts
  • web/packages/agenta-entities/src/runnable/portHelpers.ts
  • web/packages/agenta-entities/src/runnable/utils.ts
  • web/packages/agenta-entities/src/workflow/state/molecule.ts
  • web/packages/agenta-entities/tests/unit/build-evaluator-execution-inputs.test.ts
  • web/packages/agenta-entities/tests/unit/extract-template-variables.test.ts
  • web/packages/agenta-entities/tests/unit/playground-inputs-visibility.test.ts
  • web/packages/agenta-entities/tests/unit/port-helpers.test.ts
  • web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts
  • web/packages/agenta-entity-ui/package.json
  • web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx
  • web/packages/agenta-entity-ui/src/template-format/TemplateFormatPicker.tsx
  • web/packages/agenta-entity-ui/src/template-format/index.ts
  • web/packages/agenta-entity-ui/src/view-types/FormView.tsx
  • web/packages/agenta-entity-ui/src/view-types/ViewTypeSelect.tsx
  • web/packages/agenta-entity-ui/src/view-types/formatters.ts
  • web/packages/agenta-entity-ui/src/view-types/index.ts
  • web/packages/agenta-entity-ui/src/view-types/viewTypes.ts
  • web/packages/agenta-entity-ui/tests/unit/playground-inputs-formatters.test.ts
  • web/packages/agenta-entity-ui/tests/unit/view-types.test.ts
  • web/packages/agenta-playground-ui/package.json
  • web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/ComparisonLayout.tsx
  • web/packages/agenta-playground-ui/src/components/ExecutionItems/assets/ExecutionRow/SingleLayout.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBody.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/PlaygroundInputsBodyHost.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/UnreferencedColumnsFooter.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/index.tsx
  • web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/viewModeAtoms.ts
  • web/packages/agenta-playground-ui/src/state/featureFlags.ts
  • web/packages/agenta-playground-ui/src/state/index.ts
  • web/packages/agenta-playground/src/state/controllers/executionItemController.ts
  • web/packages/agenta-playground/src/state/execution/executionItems.ts
  • web/packages/agenta-playground/src/state/execution/index.ts
  • web/packages/agenta-playground/src/state/execution/selectors.ts
  • web/packages/agenta-playground/src/state/execution/visibility.ts
  • web/packages/agenta-shared/src/utils/templateVariable.ts
  • web/packages/agenta-ui/src/Editor/plugins/token/TokenTypeaheadPlugin.tsx
  • web/packages/agenta-ui/src/Editor/plugins/token/extensions/tokenBehavior.tsx

Comment thread web/packages/agenta-entities/tests/unit/template-variable-validation.test.ts Outdated
Comment thread web/packages/agenta-shared/src/utils/templateVariable.ts
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.
…nputs

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.
@ardaerzin ardaerzin changed the title Fe feat/mustache support [FE feat] Mustache support May 27, 2026
mmabrouk added a commit that referenced this pull request May 27, 2026
Restores the beautified Outputs / Inputs rendering for spans whose
ag.data.outputs or ag.data.inputs arrives as a JSON string (langchain
AgentExecutor returnValues, ChatOpenAI LLMResult generations, n8n traces).

Background: the May 22 refactor (97cb574, merged via #4394) made
tryParseJsonValue strict so the testcase / playground editors stop
auto-parsing strings into objects. extractPreview from #4410 was
written against the old behavior and silently broke. With the new
strict helper, every rule's isPlainObject check failed on string
inputs and the cell fell through to raw stringified text.

Adds a private tryParseJsonString helper inside extractPreview.ts that
unstringifies container-shaped JSON before rule matching. Kept private
so it cannot leak back into surfaces that intentionally moved to
"strings stay strings" (cell renderers outside the dispatcher,
testcase drawer, playground inputs in #4465).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Frontend size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant