✨ Add <Elicit> and the Elicitation Context Api - #264
Merged
Conversation
A document that needs a person's decision could only get one through <WebForm>, which is a browser form by construction — RJSF, a uiSchema prop, a loopback server, a browser launch. Writing it picks a transport, and a workflow that should run under a terminal, an editor integration, or a test harness cannot. <Elicit> separates the question from the channel. The document declares what it asks and what shape the answer must have; a host installs the provider through the Elicitation Api. Core keeps everything that is a property of the question — compilation, validation, diagnostics, durable recording and replay, interruption — and the provider keeps only its live interaction. The elicitation path is a module a host can call, not something private to the component: prepareElicitation/runPreparedElicitation for a caller that needs compilation to precede its message, joined by elicit() for one that does not. That is what lets #260 elicit with no document executing. Ordering is the contract. The schema compiles first, so an unusable one fails before the body expands and before anyone is asked; the body expands into the request on every execution including replay, because it is half of what identifies the question; the answer is validated against the same schema before it binds. Only the validated answer is journaled, and a recorded answer whose question does not match this run is refused rather than bound — only type and name decide whether a journal entry matches, so without that guard a resumed document could bind an answer nobody gave. Two refusals are core's rather than any provider's, so validity never depends on which provider is installed: a __proto__ declared name, and a $ref that leaves the schema. Both report their position. schema-walk and the canonicalizing fingerprint move from packages/web to core to serve them; web imports both and its digests are unchanged. Tests install real middleware on the Api rather than stubbing anything, and scriptElicitations() is the queue a markdown test uses — one answer per live elicitation, failing when it runs dry and at teardown when answers are left over.
PR #264: ✨ Add
|
This was referenced Jul 31, 2026
Two documentation-accuracy fixes from review.
elicit-journal.ts's header said a mismatch was somebody else's business — "the
repository's ordinary durability semantics take it from there; nothing here
decides what a mismatch means" — while the guard immediately below decides
exactly that. It now says what the file does: nothing else compares the
fingerprint, so a recorded answer that is not this run's question is refused
with a StaleInputError. Why refusing is as far as it goes stays on the guard
itself rather than being repeated.
The CLI's WebForm provider is PR 2's, and three places said it already existed:
the no-provider diagnostic, spec §6.16.1's provider-selection paragraph, and
the site's Elicit section. A fourth, Elicit.ts's header, said the same thing and
is fixed with them. Each now names what is true today — a host installs the
provider it wants, and tests install scripted middleware — and describes a
browser form as one shape of provider rather than the current one. PR 2 restores
the specifics in the slice that makes them true.
The diagnostic also gained the `{ at: "min" }` position, which a host reading it
needs and which the surrounding docs already carried.
The header pointed at `refuseChangedQuestion`; the function was called `guardRecordedQuestion`. Renamed rather than fixing the word: refusing is what it does, "guard" undersells that it errors, and `<TempDir>`'s `refuseReplayInside` is the same kind of function under the same convention.
| }; | ||
|
|
||
| // Before the record is read, so a resumed document cannot bind an answer that | ||
| // was given to a different question. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
Suggested change
| // was given to a different question. |
4 tasks
taras
added a commit
that referenced
this pull request
Aug 2, 2026
Reworked against #267's revised contract: an ordered values array said nothing about which question each answer belonged to, so the answers are now matchers that name the question they answer. <Answers> and <Answer> are structural. A construct whose children are read as structure rather than rendered text cannot be an ordinary registered component, which only ever sees content() — so both are claimed through the expansion hook as <Test> and <WhenPrompt> are, and reserved alongside the other structural names. Claiming them means partitioning matchers from body before anything expands, which is what makes matcher placement free and lets "an <Answers> with no body" be a structural fact rather than a guess from empty rendered text. The template engine is hoisted from packages/test-agent to core and re-imported, following #264's consolidation pattern. TestAgent's suites pass unchanged: the only diff in its test files is the import path, with every assertion untouched. Selection is first-declared-wins plus reusable matchers, which together make declaration order significant — a broad template above a narrow one shadows it permanently, stated as contract rather than discovered. delegate keeps its meaning for unmatched elicitations, and the default failure names the message and every template tried, in PromptMismatchError's style. The provider install is scoped to the body's expansion. Without that it lands on the enclosing expansion's scope and the region goes on answering elicitations written after its closing tag — which the "stops answering once its body is over" test caught. evaluateExpression is exported from expand.ts because a claimed construct resolves its own props: the hook is offered the element before prop resolution runs, so value={...} and delegate={...} are read here. The {binding} asymmetry between the prop and children template forms is documented in both specs rather than normalized. The matching constraint is identical across the two; only which layer reports an absent binding differs, and normalizing would reach into <WhenPrompt>'s behavior.
This was referenced Aug 2, 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.
PR 1 of the #197 chain, on top of merged PR 0 (#263). Core only — the WebForm provider adapter and the CLI composition are PR 2.
Why
A document that needs a person's decision could only get one through
<WebForm>, which is a browser form by construction. Writing it picks a transport, and a workflow that should run under a terminal, an editor integration, or a test harness cannot.What changes
<Elicit schema={…} as="…">asks the same kind of question without saying where the asking happens.schemaandasare required and that is the whole surface — nomode, noprovider, nouiSchema, no built-in approve/decline/cancel. The host installs a provider through the Elicitation Api; swapping it changes no Markdown.How it works
Three steps, and the order is the contract:
The elicitation path is a module a host can call, not something private to the component —
prepareElicitation/runPreparedElicitationfor a caller that needs compilation to precede its message, joined byelicit()for one that does not. That is what lets #260 elicit with no document executing, and it is the same split #252 reviewed forliveForm.Review guide
Start with:
packages/core/src/elicitation-api.ts— the whole provider contract is one operation.Then:
elicit.ts(the two halves and the hoisted refusals) →components/Elicit.ts(ordering) →elicit-journal.ts(the guard) →elicit-script.ts→ the suites → spec §6.16 / §6.16.1 and the site page.Look carefully at the replay guard in
elicit-journal.ts, which is the least obvious thing here — see below.Three things I found that shaped the design
1. A recorded fingerprint is inert without a guard. Only
typeandnamedecide whether a journal entry matches —inputis stored but "never compared during divergence detection". So the fingerprint the ruling asked for would have been decorative: a resumed document would bind a recorded answer regardless of whether it answered this question.<Elicit>installs aReplayGuardthat compares it and refuses on mismatch, following<TempDir>'sStaleInputErrorprecedent.It refuses rather than re-asks, because a guard's only outcomes today are
replayanderror—ReplayOutcomenames re-execution as future work. Failing where the question changed is the safe half; the other half belongs with #218.Note this also means
<WebForm>'s fingerprint is currently unenforced in the same way. I have not changed that here — it is not this PR's file — but it is worth an issue.2. Editing a document does not change the question on replay.
import_componentjournals the root document's source, so a partial replay runs the recorded text. A test that edited the file and resumed proved nothing, and is now written to state that behaviour instead. The guard is exercised by a journal whose recorded question genuinely differs.3. A provider must install at
{ at: "min" }. Measured, not assumed: at the default position an outer install answers ahead of a nested one — the opposite of what a provider is. Atminthe nearest answers and the outer is restored. This is documented on the Api and used byscriptElicitationsand both suites.What must stay true
__proto__and external-$refrefusals are core's, with positions.How to verify it
Beyond the suites:
deno task xmd test packages/core/src --rawrunsElicit.test.mdthe way CI does.Two contract items are asserted differently than the issue's wording suggests, because the wording does not survive contact with the platform:
nameandmessageand nothing else — the journal protocol reconstructs it. So the structuredissuesare asserted where they are raised, and their rendering is asserted where a document reads them.<Elicit>source context." A provider's own error propagates verbatim. Wrapping it would replace the only description of what went wrong, and catching around ayield*to add context would also intercept halt. Core's own diagnostics — no provider, schema refusals, response validation — all name<Elicit />.Scope
Included
The Api, the host-callable path, the component and its registration, the journal and its guard, the scripted-response helper,
Elicit.test.md, two unit suites, spec §6.16 + §6.16.1, and the site section.Two consolidations the rulings pull in, both mechanical:
schema-walk.tsmoves web → core, and the canonicalizing fingerprint moves web → core ascanonicalize/canonicalFingerprint. Web imports both and deletes its copies. Web's digests are unchanged — each call site keeps its own composition, and I pinned five fingerprints before and after to prove it.Intentionally unchanged
<WebForm>'s own behaviour, including its unenforced fingerprint.parseDeclaration's approved label parameter, which only the adapter needs.Test results
CI-pinned Deno 2.9.1: lint 0 errors · check clean ·
deno task test313 passed/0 failed ·check:jsrsuccess ·xmd test packages/core/srcexit 0 ·tsc --project tsconfig.node.jsonclean ·pnpm test:node2118/0 ·pnpm test:bun2118/0 · sitecheck+buildclean.Scope confirmation