✨ Slice C of #854: ask bounded Elicit forms in the REPL - #860
Merged
Merged
Conversation
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
force-pushed
the
agent/issue-854-forms
branch
from
September 30, 2026 15:32
6105c4f to
2f0ba00
Compare
This was referenced Sep 30, 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.
Part of #854. Slice C of five.
Stacked on three merged foundations, all already on
main:<All>/<Spawn>concurrent document work.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:
<Elicit>in a REPL entry had no way to be presented or answered.After:
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; anythingoutside it throws
ElicitationProviderErrornaming the offending keyword andits 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 refusedschema asks nobody and leaves
pendingundefined.Two conditional shapes are refused because Core would read them differently
from anything this form can draw:
$.if.properties.<field>.type— Coreevaluates that type, while the form reduces the condition to a string
equality;
ifwith norequired, at$.if.required—propertiesdoes notrequire a property to exist, so Core applies
thenwhere the field isabsent. 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 APIwas 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 aperson 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×20terminal leftdrawer: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 focusroot while the drawer is open, and is not also drawn beside it.
Focus restoration is causal, not positional.
ReplState.restorefor ananswer carries
{ kind: "answered", known, answer }— every answer the historyalready 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
scroll position are process-local: they never enter the route and never
enter the Journal.
byte-identical after parsing.
How to verify it
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-diffreports the rebase as patch-identical, so theystill 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
75662a2bdescribes the old base and is not reusable. One signed weightscommit 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
restoration.
Intentionally unchanged
Scope confirmation