Repository navigation
Conversation
A host that means to discuss something with an agent and show somebody what the reply *says* needs three things core did not have. **Evaluate the reply as a root.** `evaluateGeneratedXmdRoot()` admits source through the same walk, the same durable record and the same pinned resolution a fragment gets, and differs over exactly one construct: a top-level `<Output>`, which is what lets a reply select what it renders. The admission's context travels with its policy, so a root admission does not resume as a fragment and a fragment admission does not resume as a root — and because a fragment record states no context, every record already written stays readable and resumable as what it is. The walk's invariant inputs are bundled rather than threaded, so the one difference is read from where the rest of the decision is instead of copying the policy walk per context. It answers with a `Result`, because the two kinds of failure are different things for a host to hold. Source core refused, and work the root's own elements failed at, are the request's and come back as `Err` carrying what the root had rendered. A durability failure, an absent Files provider, a secret-policy refusal and a failed teardown are not, and each keeps its own classification. **Read what an arriving prefix already says.** `previewGeneratedXmdRoot()` is a pure projection with no `Operation`: it reads no file, resolves no name, invokes no component, evaluates no expression and grants no admission. Source still arriving is a success that says so; source the language does not allow where it is written is a refusal. Telling those apart needs the scanner's own walk — it is the only reader that knows how far it got and why it stopped — so the scan now reports the constructs it gave up on, beside the elements it already reported. A host recognizing `<Output>` with a regular expression would be a second reading of one syntax. **Hold a conversation open.** `useAgentConversation()` is the conversation a document's `<Session>` and `<Prompt>` cannot be: no element to name a placement, no journal already around it, and a history that outlives the process. It is an execution the host keeps open, and three things follow. It is an *element* — a host-declared identity component in a synthetic root of core's own — because a provider and its placement coordinator are installed inside `Execution.document` and a preparation precedes both, so the session is placed under the engine's own claimant-issued identity rather than under anything the host supplied. Its root is never completed, because a recorded terminal replays instead of reopening: the element serves turns until the resource is released, and the halt appends nothing. And each turn is one journaled Prompt under its own durable name, run in a task of its own, so cancelling the call halts the turn, leaves its append unmade, and makes the next turn a new turn rather than that one resumed. A turn whose outcome the history does not hold refuses the conversation before it opens — resuming it would re-send a prompt the provider may already have accepted, and writing a terminal for it would make an unfinished turn look complete. The history is bound to the conversation it belongs to, so one established for another id or another agent refuses rather than reconnecting the wrong chat. `runPrompt` is now shared rather than copied: the component and the host operation both reach the one definition of what a canonical turn is. Covers C1, C2 and C3 of the issue's frozen matrix.
…ed (#883) Two Planner findings against `d45bbfc6`. **P1 — cancelling a prompt returned before the provider had stopped.** The caller's cleanup resolved the cancellation signal and returned; the worker then halted the turn, but that worker belongs to the long-lived conversation rather than to the cancelled caller. So a halt that had returned proved nothing about whether provider work was still running — and a CLI cycle cannot build on that. The caller now waits for the turn to finish unwinding before its own halt completes. The worker reports that by resolving a signal from an `ensure` registered *before* the turn is spawned, which is what makes it the last thing to unwind: cleanups registered later run first, so the turn and every cleanup the provider registered inside it have finished by the time the caller is released. The conversation stays unavailable to another turn through that cleanup, because it is not free until the previous turn's work is over. A failure while the turn unwinds stays an authoritative teardown failure, and the conversation closing ends the wait too — then there is nothing left to do the joining. **P2 — nothing tested the native conversation an established history names.** The previous evidence compared the logical host id and the agent, which are different contracts. A conversation's established native identity is now read from the canonical turn records themselves: a completed Prompt retains the exact conversation the provider said that turn ran in, so the journal already holds the establishment. That is what lets a turn accepted before anything acknowledged a mapping be reconciled to *that* identity rather than leaving the conversation looking unestablished and open to a different one — and it needs no mapping record of its own, so there is no second account to disagree with the first. A provider that resolves a different native conversation is refused before the turn starts, where refusing costs nothing. One that names a different conversation only after the turn has run is refused too, and the established identity stands rather than being replaced. A history whose own turns name two different conversations cannot be opened at all: there is no rule for choosing between them that is not a guess. CV18–CV19 are the held-teardown regressions, CV20–CV24 the identity ones. Each was checked against its own negative control: removing the caller's join fails CV18 alone, removing the pre-turn check fails CV21 alone, removing the late-assertion check fails CV23 alone, and removing the two-identity refusal fails CV24 alone.
The provider's commit order is the contract: the adapter accepts the session, ACPX's own record is promoted to assert an identity, the host's mapping is acknowledged second, and the session becomes established last. The window between the first two is the one a chat has to recover from — the agent is in a conversation nobody wrote down — and no stub provider can show it, because a stub has no acknowledgement to interrupt. Tier HC drives the real provider for that: `installAgentComponents` with `createAcpxProvider` as the root provider, exactly as `installAgentProviderStack` assembles it, with ACPX's runtime replaced by the scriptable fake this package already drives it with and the host's `established` hook as the one seam a case refuses. `provider.test.ts` SM9 says that window leaves one canonical assertion; these rows ask what the conversation can still do after it. HC1 is the window itself. The interrupted turn never started, so it retained no identity, and what the next turn joins is decided by the provider's own record: reopening reconciles to that same assertion, commits it, creates nothing in its place and does not resend the interrupted prompt. HC2 is the same interruption once a completed turn has retained an identity, where the journal is the account that survives — the refused turn is its own recorded fact and the chat is still the conversation its first turn established. HC3 is what that identity is for: a provider answering with another conversation has that turn's outcome refused, and a history whose own turns name two of them cannot be opened. CV22 claimed the first of these and did not test it; it is renamed to what it proves — reopening joins the identity its retained turns name, without resending them — and points at HC1 for the window before a turn has retained one. The architecture section and §A conversation a trusted host holds open now state the established-identity rule and the cancellation join the revision introduced. Each row was checked against its own control: leaving the acknowledgement alone fails HC1 alone, and then HC2 alone; letting the provider answer with the same conversation fails HC3 alone. Against the parent conversation source HC3 fails and HC1/HC2 pass, which is the expected shape — their reconciliation is the provider's work, and HC3 is the row the Core change owns.
A root could not write an entry draft, which is the one thing the following CLI
consumer needs it for. The accepted program captures entry source with
`<Let select="code">` and hands the captured string to a paired control, and
admission refused it twice over: `{code}` is a read, and so is the `{names}`
inside the captured passive fence. I had written that refusal in as GR17 on the
premise that a root inherits nothing and therefore needs the fragment's rule.
The premise was wrong in both directions, which is why the fix is two things
rather than one. A root *did* inherit the admitting document's bindings — a
root evaluated inside a host component rendered `{secret}` as that document's
value — so the refusal was load-bearing for disclosure and relaxing it alone
would have let a reply read a host binding by naming it, and let a captured
draft carry one into an entry. But a root driven from a preparation had no
environment to bind into either, so `<Let>` had nothing to write to.
So a root is now given an environment of its own — empty bindings, empty meta,
empty props — installed on the expansion scope, and only then does its
interpolation become admissible. The only names it resolves are the ones it
bound itself; every other brace renders exactly as written, which is what lets
an entry's own braces and nested fences reach the control as the text they are.
A fragment is deliberately untouched. It is admitted *into* a document and
expands against that document's environment, which is why its rule is that it
may not read a binding through interpolation at all. Released fragment
behaviour and records are unchanged: 28 tests, 289 steps, as before.
GR17 is inverted to what the contract actually says — a root reads a binding it
bound itself. GR18–GR21 are the accepted flow: a literal fill reaching the
paired receiver exactly once, a captured passive fence, the accepted
Each/greeting source with its entry-only references intact and none of it run,
and a nested passive fence. GR22 is what the environment is for: a root does
not read the bindings of the document that evaluated it, in rendered chat or in
a filled draft. The GR9 row asserting that a non-existent binding refuses a
root is gone; it is literal text now.
Each half is necessary and was checked by removing it: restoring the blanket
refusal fails GR17 and GR19–GR22, and removing the environment fails the same
rows — the first because admission refuses the capture, the second because
`<Let>` has nowhere to bind. The reviewer's archived passive-draft probe passes
unchanged, as does the cancellation probe.
This was referenced Oct 11, 2026
This branch has not been deployed
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.
Closes nothing on its own — PR 1 of 2 for #883.
Why
xmd repl's Sidekick needs three things a document already has and a host doesnot: a way to evaluate generated XMD as a root rather than a fragment, a way
to show source that is still being typed without guessing at it, and a way to
hold one agent conversation open across a process. Today a host can reach none
of them:
<Output>is refused at the top level, the scanner reports nothingabout the construct it gave up on, and
<Session>/<Prompt>only exist insidean expansion with an element to name the placement and a journal already around
them.
This PR adds exactly those three capabilities and the specification they answer
to. It ships no Sidekick; PR 2 is the CLI.
What changes
Before:
<Output>was refused, and a fragment'sretained admission could be resumed as a root.
guess from a failed parse which refusals meant "not allowed" and which meant
"not finished yet".
DurablePreparationruns before
Execution.document, so one opened there reaches no provider andno placement coordinator.
After:
evaluateGeneratedXmdRoot(source)admits the fragment grammar plus oneconstruct — a top-level
<Output>— and the context is a term of theretained policy, so a root admission never resumes as a fragment or the
reverse.
previewGeneratedXmdRoot(source)is a pure prefix projection, noOperation.The scanner now reports what it abandoned (
UnfinishedConstruct), which iswhat tells "still arriving" from "not allowed" without a second grammar.
useAgentConversation(request)opens the genuine thing over aDurableStreamthe host supplies: canonical Session placement, journaled turns, verified
configuration readback, and a native identity the provider reattaches to.
How it works
The conversation is a host-declared identity component written into a
synthetic root core owns (
agent/conversation.md), because that is the onlyposition from which a session can be placed: canonical resolution hands the
element this execution's claimant, so the session runs under the engine's own
identity rather than under anything the host supplied. The host's
idnames thehistory and nothing else.
Its root is never completed. A durable root that records its terminal
replays instead of reopening, and reopening a chat is the ordinary case — so the
element serves turns and does not return, and the halt that releases the
resource appends nothing. What the history keeps is a partial continuation.
Each turn is one journaled Prompt under its own durable name, offered in the
conversation's sequence exactly as
<Prompt>offers one in a document's andthrough the same
runPromptthe component uses, run in a task of its own socancelling the call halts the turn.
Review guide
Start with:
packages/core/host.ts— the three exports and why each exists.Then review:
specs/acp-client-spec.md§A conversation a trusted host holds open, andspecs/executable-mdx-spec.md— the contract this implements.packages/core/src/agent/conversation.ts— the whole C3 capability.packages/core/src/generated-xmd.ts—GeneratedContextthreaded throughadmission, the retained
Policy.context, and theResultboundary.packages/core/src/generated-xmd-preview.tsandsrc/scanner.ts—the projection and the unfinished-construct reporting it needs.
Look carefully at:
deliver()and the caller'sensureinhandle(): the cancellation join.The
ensurethat reports a turn joined is registered before the turn isspawned, which is what makes it unwind last — cleanups registered later run
first, so the provider's own cleanup is finished before the caller is
released, and
busystays set through it.ordinaryGeneratedFailure(): which failures becomeErrwith a partialrendering and which are rethrown as themselves.
What must stay true
by
contextbeing a term of the retainedPolicycompared inpolicyHolds(), checked by GR13 and GR14.contextonly for a root, checked by GR10/GR11/GR15 and the whole releasedfragment row.
admits no second turn until it ends — enforced by the pre-spawn
ensureandby
live.busyclearing in the caller's ownfinally, checked by CV18/CV19.enforced by comparing the resolved and recorded
agentSessionIdagainst whatthe retained records establish, checked by CV21/CV23/CV24 and HC3.
opens — so a refusal never appends a terminal to the history it refused,
checked by CV14.
How to verify it
Errwith the truthful partial rendering, and fail if the partial were dropped or a
refused admission were allowed to carry one (GR8).
if
contextwere left out of the policy comparison.and fail if the preview inferred "still typing" from any parse failure.
and fail if it became an
Operationthat could.a caller's halt does not return until the provider's blocked cleanup finishes,
and fails if the caller merely signals cancellation and leaves. CV19 then
proves fresh explicit work still starts, so the fix is not a deadlock.
refused before the turn, refused after it, and unopenable when the history's
own turns name two conversations.
provider the way
installAgentProviderStackdoes, with ACPX's runtimereplaced by this package's scriptable fake and the host's own mapping
acknowledgement refused once — the ordering
provider.test.tsSM9demonstrates. The provider's assertion survives, the owner is released, and
explicit new work reconciles to that same conversation, keeps one store
record and does not resend the interrupted prompt. It fails if a reopening
created a replacement conversation or re-sent accepted work.
Also run:
deno task check(exit 0),deno task lint(0 errors, format clean),deno task validate:docs. The new test file was run under Node and Bun as wellas Deno, so it needs no runtime exclusion.
Scope
Included
evaluateGeneratedXmdRoot(),previewGeneratedXmdRoot()anduseAgentConversation()on@executablemd/core/host.architecture.md,specs/acp-client-spec.mdandspecs/executable-mdx-spec.mdupdated to match.Intentionally unchanged
which starts from this commit.
packages/acp/src/provider.ts. Tier HC was written to find out whetherthe provider's commit window needed a production change. It did not, so there
is none; the file is untouched and only gains a test alongside it.
evaluateGeneratedXmd()and its recordsbehave exactly as before, which the preservation row asserts.
New abstractions
GeneratedContext/ROOT_CONTEXTexists because the one thing separating aroot from a fragment is a ceiling, and a ceiling that is not retained with the
admission can be changed between runs. Consumers: admission, the policy
comparison, and the record.
UnfinishedConstructexists because the preview's whole question — is thisnot allowed, or just not finished — is unanswerable from a parse failure
alone. Consumers:
previewGeneratedXmdRoot().AgentConversation/AgentConversationRequest/AgentConversationErrorexist because a host conversation is a resource with a mailbox, not a door:
the handle reaches the element only through its queue. Consumers:
useAgentConversation()and, next, the CLI Sidekick.readsBinding()insrc/generated-interpolation.tsexists because admissionand preview must apply one interpolation rule; two copies would let a
preview promise what admission refuses.
Each new abstraction has multiple concrete uses or a clear justification.
No speculative functionality is included.
Generated or mechanical changes
packages/core/src/agent/function-components.tsonly exportsrunPromptandCarried, so the component and the host operation reach one definition of acanonical turn instead of a copy. No behavior change.
packages/core/src/errors.tsonly exports the existingfirstCause.Risks and limitations
it owns is always a partial continuation. A consumer that treats an absent
terminal as corruption would misread it; the spec states this.
reconciled, by design — reconciling it needs the provider's own account of
that turn, which is the host's to fetch. PR 2 is the first consumer that will
have to surface that refusal to a person.
them yet, so reverting this commit removes the capability without touching any
existing path.
Scope confirmation