Skip to content

✨ Slice C of #854: ask bounded Elicit forms in the REPL - #860

Merged
taras merged 4 commits into
mainfrom
agent/issue-854-forms
Sep 30, 2026
Merged

taras merged 4 commits into
mainfrom
agent/issue-854-forms

Conversation

@taras

@taras taras commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Part of #854. Slice C of five.

Stacked on three merged foundations, all already on main:

Why

The REPL could run a document and follow its Agent turns, but an <Elicit>
reaching it had nowhere to be asked. A question needs a form: fields, options,
validation, and somewhere bounded to draw a long message.

What changes

Before:

  • An <Elicit> in a REPL entry had no way to be presented or answered.

After:

  • The REPL asks bounded Elicit forms in its drawer, validates submissions
    through Core's own compiled schema, and restores focus to the answer the
    person actually gave.

How it works

The supported schema language is deliberately closed. readQuestionForm()
parses an accepted subset into a deeply frozen ReplQuestionForm; anything
outside it throws ElicitationProviderError naming the offending keyword and
its JSON path. This is not a general JSON Schema renderer and is not meant to
become one — a form the REPL cannot draw faithfully is refused rather than
approximated.

Refusal happens before anything is published: the whole schema is read
before prepareElicitation() and before the asked counter moves, so a refused
schema asks nobody and leaves pending undefined.

Two conditional shapes are refused because Core would read them differently
from anything this form can draw:

  • a tested type other than string, at $.if.properties.<field>.type — Core
    evaluates that type, while the form reduces the condition to a string
    equality;
  • an if with no required, at $.if.required — properties does not
    require a property to exist, so Core applies then where the field is
    absent. It is refused rather than defaulted to the tested field.

Invalid submission keeps the same question open, reporting normalized
messages keyed to the field that failed; valid submission resolves once with
exactly the validated object. What judges a submission is Core's compiled
schema through prepareElicitation() + validateParsed() — no new Core API
was needed.

The whole question scrolls, through one bounded viewport. The complete
ordered content — the whole message, the form's description, every field with
its annotation, offered values and editable line, every validation message, and
[submit] — is built in order and then windowed. The two scroll controls,
[history] and [close] stay outside that viewport, because they are how a
person moves it and leaves. Rows outside the window are not described at all,
so they are neither drawn nor pointable, and focus is claimed only by a row the
viewport actually shows.

This is what the review corrected. Capacity used to be the drawer's height less
every form row, which at the minimum accepted 72×20 terminal left
drawer:value:feedback, [submit] and [close] described but never placed —
and a row layout cannot place is not something a person can see or point at.

Editing is scalar-safe: one erase removes one Unicode scalar, not one UTF-16
code unit. The single [history] node sits inside the drawer's modal focus
root while the drawer is open, and is not also drawn beside it.

Focus restoration is causal, not positional. ReplState.restore for an
answer carries { kind: "answered", known, answer } — every answer the history
already held, plus the exact object sent — so the claim resolves to the record
that is new and holds that answer, not the newest one and not one this
answer did not cause. The claim is silent while its row is unprojected and is
spent at the commit where the named control actually takes focus, so later Tab
navigation is not pulled back.

What must stay true

  • Nothing about a form is durable. Form values, validation messages and
    scroll position are process-local: they never enter the route and never
    enter the Journal.
  • A caller's schema is not mutated. A retained schema stays unfrozen and
    byte-identical after parsing.
  • A refused schema is inert. It publishes nothing and asks nobody.

How to verify it

deno task test \
  packages/cli/tests/repl-forms.test.ts \
  packages/cli/tests/repl-journey.test.ts \
  packages/cli/tests/repl-composition.test.ts \
  packages/cli/tests/repl-boundaries.test.ts \
  packages/cli/tests/repl-execution.test.ts
# ok | 29 passed (157 steps) | 0 failed   — exit 0

deno task check                      # exit 0
deno task lint                       # exit 0
git diff --check origin/main...HEAD  # exit 0

The count rose from the reviewed head's 28 / 154 with the correction's new
evidence: the placed-frame walk, the viewport's message walk, two exact-path
refusals and two comparisons against Core's compiled validator.

Three named controls, each applied alone to the final sources and reverted:
overfill-the-drawer reddens the placed-frame row because essential
targetable controls never reach the frame; accept-non-string-condition and
invent-if-required each redden their exact-path refusal and their
semantic comparison.

Eighteen refusal rows assert the exact JSON path of the rejected keyword. Nine
deliberate defects each turned named rows red — accepting an unknown keyword
reddens the unknown-keyword refusals and the publishes-nothing row; keeping
only the first property or enum value reddens both parse rows, five submission
rows and both drawing rows; resolving the provider before local validation
reddens the two keeps-the-question-open rows. They were not re-run for this
delivery: git range-diff reports the rebase as patch-identical, so they
still discriminate exactly what they discriminated when accepted.

Measured weights

Pending on the corrected head 2f0ba006efac10cacb38762e3c1f5b8c972717b4.

No measurement is authorized yet. The two runs attempted at the reviewed head
(36708334659 and 36720937070) were both cancelled and produced no artifact, and
neither describes this head anyway. The earlier successful run 36511769162 at
75662a2b describes the old base and is not reusable. One signed weights
commit follows, with its provenance and the measured floors against the
installed shard counts (deno 15 / node 10 / bun 5), before this PR is merged.

Scope

Included

  • Bounded Elicit forms in the REPL drawer, their validation, and focus
    restoration.

Intentionally unchanged

  • Slice D Sessions and permission presentation.
  • Slice E command, packaged Plan and journeys.
  • The terminal mockup and any UI redesign.
  • A general JSON Schema renderer. The closed language is the scope.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

The REPL's Elicit provider accepted any schema and asked for one string. It now
reads the schema into a bounded form language — string fields, enums, minLength,
annotations and one if/then condition — and refuses anything else by name and
JSON path before it publishes a question or moves its counter.

What judges a submission is Core's own compiled schema, reached through
`prepareElicitation()` and `validateParsed()`, so an answer is judged by the
thing that will judge it again on its way out. An invalid submission keeps the
same question open and reports each issue against the field it belongs to.

Form state is per field: values keyed by name, the field being edited, the
messages the last submission was told, and how far the read-only message region
is scrolled. None of it is durable and none of it is in the location.

The drawer draws the whole question through a clamped window whose capacity comes
from the drawer's own height, every field with its annotations and required
marker, every enum value as its own control, and the validation messages. The one
History control is reparented into the modal rather than duplicated beside it, and
the footer offers the question as a single bounded line.

When the drawer goes, focus goes to the invocation rather than to wherever the
drawer was opened from. Which invocation an answer belongs to cannot be read when
it is taken — the record appends afterwards — so the claim carries what will
identify that record: the answers already retained, and the object that was sent.
It waits through the frames until that record is projected, and the commit that
puts focus where it asked is what spends it, so traversal from there is the
person's.
The bounded form language made two sentences false. The spec said the drawer
shows "the one field the schema asks for", and the boundary row froze that
reading: one field, its choices, and `undefined` for a schema of another shape.

The drawer shows every field the schema declares, with its annotations, its
required marker and the values it accepts, and an unsupported schema is refused
by name and JSON path rather than read as none. D1 now asserts the complete form
and that `{ type: "string" }` throws `ElicitationProviderError` naming `$.type`.

The delivery gate found this: the focused three-file gate does not reach
`repl-boundaries.test.ts`, so weights run 36489324287 was the first thing to run
it.
The bounded form language replaced `ReplQuestion.answer(value)` with
`submit(values)`, and one existing suite still called the old one. Its focused
gate does not typecheck that file, so the first thing to run it was the
test-weight measurement, which stopped with fourteen `TS2339`s and wrote no
artifact.

Every call now submits the object the form assembles, and the two rows that cared
about the result say which result it was: `answered` for a value the schema
accepts, `invalid` for one it does not — where the old boolean said only true or
false. The form assertion says the whole form the reference schema reads as,
member for member, rather than the single field and its choices.

Tests only. No production code changes, and no behaviour with it.
…ently

Two things the drawer got wrong.

**Everything the form holds now scrolls, not only the message.** At `72×20` the
drawer is eleven rows, and the old shape reserved what was left after counting
every form row — so at the minimum accepted terminal the frame stopped at
`drawer:field:feedback` and `drawer:value:feedback`, `[submit]` and `[close]`
were described but never placed. A row layout cannot place is not something a
person can see or point at.

The complete ordered content — the whole message, the form's description, every
field with its annotation, offered values and editable line, every validation
message, and `[submit]` — is built in order and then windowed through one
bounded viewport. The two scroll controls, `[history]` and `[close]` sit outside
that viewport, because they are how a person moves it and leaves. Rows outside
the window are not described at all, so they are neither drawn nor pointable,
and focus is claimed only by a row the viewport actually shows. The process-local
`form.offset` now indexes that whole content, still clamped at both ends and
still absent from the route and the Journal.

**Two conditionals the form cannot represent are refused.** A tested type other
than string is refused at `$.if.properties.<field>.type`, because Core evaluates
that type while this form reduces the condition to a string equality. An `if`
with no `required` is refused at `$.if.required` rather than defaulted to the
tested field: `properties` does not require a property to exist, so Core applies
`then` where the field is absent, and a form waiting for `a === "x"` would never
ask for what Core already requires.

Evidence, each row red under its own named control:

- scrolling places every essential Plan control in a targetable cell of the
  real `72×20` frame, collected only from placed cells through `tree.keyOf` —
  red under **overfill-the-drawer**;
- the viewport walks all forty message lines, and `[submit]` never appears
  before the last of them;
- both refusals name their exact path, and two rows compare the reader against
  Core's own compiled validator: for a numeric tested type Core accepts
  `{ a: "x" }` with no consequential field, and with no `if.required` Core
  reports that field missing from `{}` — red under
  **accept-non-string-condition** and **invent-if-required**.
@taras
taras force-pushed the agent/issue-854-forms branch from 6105c4f to 2f0ba00 Compare September 30, 2026 15:32
@taras
taras merged commit db98e85 into main Sep 30, 2026
43 of 44 checks passed
@taras
taras deleted the agent/issue-854-forms branch September 30, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant