Skip to content

✨ Add <Elicit> and the Elicitation Context Api - #264

Merged
taras merged 3 commits into
mainfrom
feat/elicit-core
Aug 2, 2026
Merged

✨ Add <Elicit> and the Elicitation Context Api#264
taras merged 3 commits into
mainfrom
feat/elicit-core

Conversation

@taras

@taras taras commented Jul 31, 2026

Copy link
Copy Markdown
Owner

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. schema and as are required and that is the whole surface — no mode, no provider, no uiSchema, 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:

  1. The schema compiles. An unusable one fails here — before the body expands and before any provider is contacted.
  2. The body expands into the request message, on every execution including replay.
  3. The provider is asked and its answer judged against the same compiled schema before anything binds or is journaled.

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, and it is the same split #252 reviewed for liveForm.

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 type and name decide whether a journal entry matches — input is 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 a ReplayGuard that compares it and refuses on mismatch, following <TempDir>'s StaleInputError precedent.

It refuses rather than re-asks, because a guard's only outcomes today are replay and errorReplayOutcome names 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_component journals 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. At min the nearest answers and the outer is restored. This is documented on the Api and used by scriptElicitations and both suites.

What must stay true

  • Compilation precedes expansion. Checked by a test whose body writes a file and whose schema is invalid: the assertion is that the file does not exist, not merely that the run failed.
  • Replay repeats no provider work, but does repeat preflight. Checked by a replay whose provider throws if contacted at all.
  • A recorded answer is not bound to a different question. Checked against a journal whose recorded question was replaced.
  • Validity does not depend on the installed provider. The __proto__ and external-$ref refusals are core's, with positions.
  • The queue is exact in both directions. Running dry fails; leftovers fail at teardown.
  • Nothing is stubbed. Every test installs real middleware at the Api boundary.

How to verify it

Beyond the suites: deno task xmd test packages/core/src --raw runs Elicit.test.md the 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:

  • Normalized diagnostics. An error crossing the execution boundary keeps its name and message and nothing else — the journal protocol reconstructs it. So the structured issues are asserted where they are raised, and their rendering is asserted where a document reads them.
  • "Provider failures propagate with <Elicit> source context." A provider's own error propagates verbatim. Wrapping it would replace the only description of what went wrong, and catching around a yield* 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.ts moves web → core, and the canonicalizing fingerprint moves web → core as canonicalize/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

  • The WebForm provider adapter and the CLI wiring — PR 2.
  • <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 test 313 passed/0 failed · check:jsr success · xmd test packages/core/src exit 0 · tsc --project tsconfig.node.json clean · pnpm test:node 2118/0 · pnpm test:bun 2118/0 · site check + build clean.

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.

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.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

PR #264: ✨ Add <Elicit> and the Elicitation Context Api

20 files, +1620 / -26

Scope

🔴 PR has 1646 lines changed. Split into focused PRs.

🟡 1646 lines changed. PRs under 400 receive more thorough review.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

taras added 2 commits August 2, 2026 08:14
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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.

};

// Before the record is read, so a resumed document cannot bind an answer that
// was given to a different question.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// was given to a different question.

@taras
taras merged commit 3c6daf7 into main Aug 2, 2026
9 checks passed
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.
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