Changelog
Version 0.8.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
c33d2b0: A fourth cold audit: two CRITICALs, fourteen MAJORs, and the guards that let four of them in
Same protocol as the third — five auditors, no changelog, no git history. It found less on the surface and more underneath, which is the only progress worth reporting: the two CRITICALs were both in code less than two days old, and four of the MAJORs were defects in the guards rather than in the library.
The two CRITICALs share a root:
{ config }scoped what a chat READ and not what it ANSWERED.AparteClientlistens onwindow, and its only instance filter wasscopeToTargetId; unset, the guard returnedtruefor everything. Two config-scoped clients on one page therefore both ran a full agentic turn for every send — two provider calls, two paid completions, both replies appended into the single target the event named. A config-scoped client now declines a target whose boundary resolves a different, non-global config; a client on the global config still answers everything, which is every single-chat app. The second: opening a conversation revoked its own attachments’ object URLs, becauseclearAll()releases them and bothsetMessagesandimportTreeput the messages straight back — andexport()stores live references, so the two views share the very same attachment objects. Every image and file chip was dead on load.Three MAJORs in the turn. A mid-stream
errorevent erased everything already rendered:_handleLifecycleErrorreplaced the segments instead of appending, so a partial answer plus an error became an empty bubble with an error in it.toolTimeoutMscould not time anything out — all three copies aborted a signal and then awaited the handler with no race, and aborting is a request a handler may ignore, which the default shape of a consumer tool does. Core’s two copies now sharewithToolTimeout. And the engine compactor could emit a window opening onrole: 'tool', which every OpenAI-compatible provider rejects with a 400: compaction turned a long conversation into an unusable one.Consent is scoped to the chat that asked. Human-in-the-loop approval matched on the model-chosen
toolCallIdand nothing else, on adocumentlistener, with built-in buttons that bubble and compose — so on a page with two chats, a click aimed at one tool could satisfy the gate awaiting a different tool in a different conversation. The check is now DOM containment: a model can choose an id, it cannot choose where a click happened. A programmatic dispatch from a host is still honoured.Three more surfaces where per-instance config did not reach what it configures.
injectRendererStyles()collected the global’s styles over an instance config’s, so a renderer registered on a config drew unstyled and silently; it now takes the config and accumulates rather than assigns, and re-creates its<style>when the old one has been detached rather than only when it is null.setupMarkedProvider(options)scoped the provider and not the options —marked.use()mutates a module singleton cumulatively, so configuring the second chat retroactively changed the first’s rendering. And the global type augmentations reached the browser entry only, so an SSR consumer silently lost typede.detailon every aparté event.Every
AparteChataccepts a caller-supplied host id.scopeToTargetIdmatchesdetail.targetId, which the wrappers set from an id they generated and neither accepted nor exposed — so the documented mechanism was unreachable from three of the four components. React, Vue and Svelte gain an optionalidprop; Angular already honoured one. The generated id remains the default.The artifact preview stops overclaiming. Its comment said everything leaving the frame is blocked. The fetch half is true and measured in three engines; the frame navigating ITSELF is not a fetch and no directive governs it —
navigate-towas removed from the spec and never shipped. No CSP or sandbox token stops it, so the fix is the claim, plus a danger block onsetArtifactPreviewBuilder, which was one line that never mentioned it replaces the policy while recommending CDN libraries.Four MAJORs were the guards.
check-doc-snippetscompiled with a WEAKER profile than the repo compiles itself with — nonoUncheckedIndexedAccess,noImplicitReturns,noFallthroughCasesInSwitchornoImplicitOverride— so a snippet could be certified while a reader’s build, following this project’s own recommended settings, rejected it. Aligned, it immediately failed the flagship getting-started example.check-export-mentionscould not read a barrel written asexport *, saw 4 names for the engine’s 39-name surface, and then certified the package already at zero unmentioned; it also credited a short export whenever a longer documented name merely contained it, and its list of barrels omitted the plugins, providers and locale-fr.check-node-barrel-typesdiffed export names, which an augmentation module has none of.check-wrapper-slotsproved nothing about the host id. All four now bite, verified by sabotage one at a time, and the export guard gained the SEEN floor that a collapsed count needs — the third guard in this repo to need it for the same reason.The wrapper reference has examples. Eleven of the sixteen slot × framework combinations appeared in no code block anywhere, in a page whose own history is the reason this project has a rule about capabilities cited in passing. The page is generated, so each slot now emits one fence per framework from the same table as the syntax column.
@aparte/core,@aparte/engine,@aparte/plugin-marked,@aparte/react,@aparte/svelte,@aparte/vue -
688a231: Remediation of a from-scratch audit: four CRITICAL and nineteen MAJOR defects, plus the guards that make each class unrepeatable.
Fixes you will notice
- Pressing Stop no longer erases the answer. A stopped turn replaced everything
already streamed with an error bubble, and never dispatched
aparte-message-aborted. Three separate paths had to be closed: an abort arriving while the loop was parked on its read,openai-compatreporting anAbortErroras a stream error whereai-sdkstays quiet, and a rejection escapingtransportCallbefore the first event. - A code fence split across deltas no longer eats the text before it, and no longer
leaks a literal
```pythoninto the message. - A split
<artifacttag no longer loses its whole lifecycle.<andartifactare separate tokens in most vocabularies, so whether artifact events fired depended on where the tokenizer cut. - A turn the human stopped, stops. A rejected tool no longer lets the rest of that turn’s tool calls run.
compact()only touches its own chat. With two clients on a page, one event made both summarise the same conversation and wiped the other with no summary.- Retrying the first message no longer resends the whole conversation. Viewport listeners no longer accumulate when the element is moved in the DOM. A stream we walk away from is cancelled rather than left generating.
Breaking
-
A previewable artifact no longer runs the model’s code without a user gesture. The card opened on Preview with the frame already mounted, so every render of a completed artifact — including reloading a persisted conversation — executed model-authored JS. It is sandboxed, so this was a prompt-injection surface rather than origin XSS. The frame is now created only when the user presses Preview, and is CSP-constrained. An app that wants it open must open the tab itself.
-
authorizeis required oncreateAparteChatHandler. The endpoint spends your server-held key, and both the JSDoc example and the docs snippet omitted it — the copy-paste path was the unauthenticated one.authorize: () => truestill works, but now someone wrote it on purpose. Vendor error bodies are summarised instead of relayed, because an OpenAI 401 hands the caller your key’s prefix and tail. -
streamRunner: runStreamAgentfinally typechecks. Making it compile required narrowingrole, mirroring the content-part union and the tool types, declaringmodelId, and removing three index signatures from@aparte/engine’s mirror types. A consumer who wrote their ownStreamAgentMessagemay need to adjust. -
@aparte/engineno longer re-exportsderiveArtifactKind— it collided with@aparte/core’s export of the same name, with a different function behind it. -
escapeHtml/escapeAttr,AparteHostHandlersConfigandAparteKeyProviderare now exported from@aparte/core. -
Ten exports are renamed, before 1.0 makes their names permanent. The four classes gain the prefix every other class already carried, and the six shared defaults gain a namespace so they cannot collide with an app’s own:
before after DirectTransportAparteDirectTransportBackendTransportAparteBackendTransportMessageRepositoryAparteMessageRepositoryConversationManagerAparteConversationManagerDEFAULT_LOCALEAPARTE_DEFAULT_LOCALEDEFAULT_UI_EVENTSAPARTE_DEFAULT_UI_EVENTSDEFAULT_ICON_FALLBACKSAPARTE_DEFAULT_ICON_FALLBACKSDEFAULT_BUBBLE_ACTIONSAPARTE_DEFAULT_BUBBLE_ACTIONSDEFAULT_HOST_HANDLERSAPARTE_DEFAULT_HOST_HANDLERSDEFAULT_SKELETON_FALLBACKSAPARTE_DEFAULT_SKELETON_FALLBACKSFunctions keep their verb names —
registerDefaultRenderers,contentToText,filesToAttachmentsand the rest are unchanged, because prefixing a verb reads worse than the inconsistency it would fix. -
The config naming is inverted:
AparteConfigis now the class, and the page-wide instance isaparteGlobalConfig.AparteConfigClassis gone.import { AparteConfig, type AparteConfigClass } from '@aparte/core';AparteConfig.setMarkdownProvider(provider);function configure(config: AparteConfigClass) {}import { aparteGlobalConfig, type AparteConfig } from '@aparte/core';aparteGlobalConfig.setMarkdownProvider(provider);function configure(config: AparteConfig) {}One
sedcovers a whole consumer, and the order matters — run the instance first, so that\bAparteConfig\bcannot yet match the class:Terminal window # 1. the instance, then 2. the class. Never the reverse.grep -rlE '\bAparteConfig(Class)?\b' src \| xargs perl -pi -e 's/\bAparteConfig\b/aparteGlobalConfig/g'grep -rl 'AparteConfigClass' src \| xargs perl -pi -e 's/\bAparteConfigClass\b/AparteConfig/g'It rewrites string literals too, so a log prefix of your own like
[AparteConfig]becomes[aparteGlobalConfig]— harmless, but check your diff if you grep your logs.Two reasons this happens now rather than never.
AparteConfigClasswas not a name, it was an admission — theClasssuffix existed only because the good name was taken by an object, and PascalCase means constructor everywhere else in the ecosystem, soAparteConfig.setMarkdownProvider(...)read as a static method to every reader. And the library moved under it: since config became per-instance, the global singleton is one config among several and the one we recommend least. Giving it the canonical name pointed at the case we want people to outgrow;aparteGlobalConfigsays at every call site which config you are touching. -
@aparte/provider-transformers:terminateWorker()no longer bricks the provider. Called while a generate was in flight, it dropped the pending streams but left their serialization slots unresolved — so the nextchat()awaited a promise that could never settle. No error, no rejection: the stream simply never started again, for the life of the page. The worker-error path already released those slots, with a comment explaining why;terminateWorker, 240 lines below it, did not.Its state is still tab-scoped, on purpose, and now says so: one worker, one loaded model, one generate at a time, with
setComputeDevice/setMaxCachedModels/setHardwareTierModelsapplying page-wide. A local model is 1–2 GB of weights and one WebGPU pipeline, so a worker per chat would mean N copies resident in one tab — the failure this package exists to avoid. Two chats on the same model share the load, which is the case it is for. Two chats on different models serialize and, at the default budget of one cached model, can evict and reload gigabytes between turns — that used to happen in silence and now warns once, naming both models.TransformersProvider.chatis also declared non-optional now, so consumers stop needingprovider.chat!(...). -
Seven documented event contracts are gone, because those events never existed. Not “undocumented” — the name appeared in the repo only in its own declaration.
aparte-artifact-opensat in the event map with a detail type asserting it is “dispatched by the artifact pill when a user clicks it”; three hits repo-wide, all three its own declaration.removed why AparteTokenEventDetailaparte-tokenis dispatched nowhereAparteMessageEventDetailaparte-messageis dispatched nowhereAparteStatusEventDetailaparte-statusis dispatched nowhereAparteToolActionDetailits JSDoc names aparte-tool-action, which does not existAparteSegmentActionEventaparte-segment-actiondoes not existAparteConversationUnarchiveDetaila dead type on a live event — the dispatcher types both archive branches with AparteConversationArchiveDetailTwo were renamed rather than deleted, because their shape was right and only the event they named was wrong:
import type { AparteArtifactOpenEventDetail, AparteSegmentUpdateEvent } from '@aparte/core';import type { AparteArtifactRedownloadEventDetail, AparteSegmentUpdateEventDetail } from '@aparte/core';AparteArtifactRedownloadEventDetailis field-for-field what the Download button really dispatches.AparteSegmentUpdateEventDetailwas a detail, not an event, and had never reached a package entry point at all — so you could bind an event listed in the published API table and never name its detail. Same forAparteConversationArchiveDetail, now exported. -
Twenty events gained a typed
detail, so the cast the docs promised you would never write is finally unnecessary. The map carried 17 entries against 37 events that dispatch a detail. Fourteen had no declared type anywhere — includingaparte-file-gen-ready/-error, where core renders a “Running sandbox…” card and waits onwindowfor an event nothing in the library emits, so a consumer had to reverse-engineer six fields from an inline cast to make a binary artifact ever finish. Six more had a public detail type and still forced a cast at every listener.Additive for your code, and
check:event-mapnow enforces both directions: an event with a detail must be in the map, and a map entry must correspond to a real event. -
AparteThemeVariablesis{ [K in—aparte-${string}]?: string }. It was a hand-written list of 33 CSS properties, ten of which are neither declared nor read anywhere in aparté — so it autocompleted ten knobs that do nothing — while the real surface is 254 tokens. You lose autocomplete and gain a type that cannot lie; the 231 tokens the old list omitted, the wholeaparte-selectsurface included, now typecheck. The discoverable list is the generated CSS-variables reference. -
One name for the imperative surface:
AparteChatImperativeApi. React exported it asAparteChatHandle, Vue and Svelte asAparteChatInstance, and Angular exposed no name at all — one contract wearing three names in a suite that publishes all four together in lockstep. Both aliases are gone; every wrapper now re-exports the canonical type straight from@aparte/core, which is also where its documentation lives, so the names cannot drift again.import { AparteChat, useAparteChat, type AparteChatHandle } from '@aparte/react';import { AparteChat, useAparteChat, type AparteChatImperativeApi } from '@aparte/react';Same rename for
AparteChatInstancein@aparte/vueand@aparte/svelte. Nothing about the shape changed — it was already an alias of the same type in all three. -
AparteAIProvideris a union instead of one permissive interface. It had three required members and fifteen optional ones, holding two mutually sufficient execution surfaces — achat()that owns its own I/O, or the format-adapter surface a transport drives — discriminated at runtime byisFormatAdapter(). So{ id, getMetadata, getModels }typechecked, registered without a word, and failed on the first message. A half-built adapter (buildRequestbut noparseStream) did the same, and so did a complete adapter with no way to present a key.Now the compiler answers “which half did you implement?”. Every member of both surfaces stays reachable on the union — optional on the arm that does not require it — so
typeof p.buildRequest === 'function'probes andisFormatAdapter()narrowing are unchanged, and a provider implementing both surfaces is still valid. If your provider was complete it compiles as before; if it was one of the shapes above, it never worked. -
Every satellite’s peer on
@aparte/coreis now the lockstep range (~0.8.0) instead of>=0.5.0-alpha.0 <1.0.0. The suite has always published in lockstep, so that range described a compatibility promise nobody was making or testing: npm was happy to install@aparte/react@0.8.0beside@aparte/core@0.5.0, and the failure landed at runtime with no warning at install. This very release makes the point — under the old range,@aparte/react@0.8.0would install against@aparte/core@0.7.1and then fail on anaparteGlobalConfigthat does not exist there.If you install the packages together, or with
latest, nothing changes. If you were pinning@aparte/corebehind the wrappers, npm now tells you at install time instead of at first render. -
@aparte/svelteships its.sveltesources instead of a precompiled bundle, and supports Svelte 4 and 5 (^4.0.0 || ^5.0.0). Nothing to change in your code, unless you were importing from a deep path insidedist. -
Every plugin
setup*takes an optional trailingconfig, so a plugin can be scoped to one chat instead of the global singleton. Existing calls are unaffected. -
AparteClientacceptstoolTimeoutMs, matchingrunStreamAgent’s option of the same name — it was previously a hard-coded constant, so setting it worked on one loop only.
Security
Nine private copies of the HTML-escaping helper became one; three of them had drifted to leave the apostrophe through, which is enough to break out of a single-quoted attribute. 42 unescaped attribute interpolations were swept (the audit reported 3). Segment lookups are scoped to their own children, so a decoy
data-segment-idin model markdown can no longer hijack a human-in-the-loop control. A style declaration containing a backslash is rejected outright, and adata:image URL must name its subtype.srcsetnow goes through the same URL allowlist assrc. It had only ajavascript:/vbscript:substring test, sosrcset="data:text/html,<script>..."passed untouched while the identical URL onsrcwas rejected — one allowlist giving two answers depending on which attribute carried the URL. Each scheme in the value is validated rather than splitting on commas, because a legitimate base64data:URL contains one.data:image/svg+xmlis deliberately KEPT in the allowlist, contrary to an earlier plan to drop it: inside an<img>a data-URL SVG is secure-static in every engine (no scripts, no external fetches), and removing it would break a model emitting an inline chart. What it must not do is travel — an app that moves such a URL into an<object>,<embed>or an iframe leaves secure-static mode, and that constraint belongs to whoever re-hosts it.Also in this release
-
Elicitation stops inventing refusals. With no presenter registered,
requestUserInput()resolved{ action: 'cancel' }in silence: your tool reported a refusal the user was never asked for, and the model answered as though they had declined. It still resolvescancel(a question nobody can render cannot be awaited) but now warns once, and the guide shows the<aparte-elicitation>element you must place — it registers itself on connect, so nothing happens until it is in your markup. -
@aparte/sveltepublishes resolvable types.typeswas not first in itsexportsblock, and export conditions are order-sensitive, so TypeScript could not resolve the package’s types at all. Thesveltecondition now carries its owntypes, matching how core’snodecondition is built. -
escapeHtml/escapeAttr/cssEscapeare documented with an example instead of being mentioned in passing: which one belongs in markup, which in an attribute, which in a selector, and why the apostrophe matters. They were exported all along while a comment in their own file claimed they were internal. -
AparteClientOptions.toolTimeoutMsis in the config reference. Theconfigargument every pluginsetup*takes is documented with an example — it was named nowhere, and it is what lets two chats on one page use different providers. -
Segment renderers are per config too, so the
configprop is now honest end to end.registerSegmentRenderer,unregisterSegmentRenderer,getSegmentRenderer,getAllRenderers,collectRendererStyles,registerDefaultRenderersanddeclineDefaultRenderersall take an optional trailing config; omitted, they act on the ambient or global one exactly as before, so no existing call changes.This was the other half of the wrappers’ promise. A plugin’s providers were already scoped, but the registry deciding WHICH renderer draws a segment was a module-level Map — two chats on a page shared their segment renderers whatever config they were given.
AparteClient({ config, autoRegister: false })was affected the same way: it declined the built-ins on the global config rather than on its own, muting the wrong chat. -
thinkingDelimitersis documented, including the two pairs recognised by default and the fact that only the bring-your-own-loop path can reach it.
A Safari bug the new browser suite found, and closed
In framework mode — what every React / Vue / Svelte / Angular consumer runs — a streamed transcript settled a deterministic 31px short of the bottom on Safari and stayed there, so the last line of a reply sat under the fold. A timeline of a streamed turn showed the content settling in TWO layout passes (1118 → 1121 → 1152 px):
scrollTop = scrollHeightran against the middle one and nothing ran again afterwards. Auto-follow was armed the whole time — the viewport was not disarmed, it was satisfied, because “am I at the bottom?” is answered with a 50px tolerance that is right for keeping auto-follow armed and wrong as a definition of anchored.Fixed by a bounded re-check over the next few frames, which stops as soon as the gap is closed and re-reads the auto-follow flag every frame so a reader who scrolls away mid-settle is left alone. It exists only because a browser test drove a real progressive stream through a real engine; no unit test could see it, and the suite that shipped before this release delivered every reply atomically. every package
- Pressing Stop no longer erases the answer. A stopped turn replaced everything
already streamed with an error bubble, and never dispatched
-
7d6652a: A third cold audit, and the one CRITICAL it found
Five auditors, five dimensions, no access to the changelog or the git history — because a previous round proved seven of the maintainer’s own claims false, and an auditor who reads the changelog is grading the essay rather than the code. One CRITICAL, twenty MAJOR. All twenty-one are closed here.
The CRITICAL, and its family.
<aparte-elicitation>registered its presenter on the config it could resolve atconnectedCallback. All four wrappers callAparteChatHost.bind()— which runsattachConfig— from a post-mount hook, so the element connected before the boundary existed and registered on the global singleton.requestUserInput()then resolved the instance config, found no presenter, and returned{action:'cancel'}: the model was told the user refused a question the user was never shown. Silent, and in the supported multi-chat path.An earlier sweep for this bug class fixed every element that READS its config live and missed both that WRITE to it — a write has already happened, so resolving live cannot save it.
attachConfig/detachConfignow notify the subtree, and a registrant implementsAparteConfigAware.aparteConfigChanged(next, previous). Three MAJORs shared the root cause:<aparte-model-selector>cached its config (and its subscription) at connect; a segment renderer registered the documented way landed on the global and was invisible to any chat with aconfigprop — an instance config now inherits global registrations; and all four wrapper conversation-manager hooks wrote the manager to the global, makingconfig+ persistence a silently degraded mode.init(adapter, config?)on all four.The streaming seam lost text three ways. A non-streaming (string) reply skipped the parser flush, so a reply ending on a backtick or
<lost that tail and one made only of those rendered nothing. The XML machine finalized after the parser flush, so the text it hands back — always a prefix of<artifact— reached a parser that would never be flushed again; the loss was total. And the adapter’s pre-tag path could add a segment but not update one that had just completed, freezing a code block mid-fence. The parity suite gained the two scenarios that missed all of this by a delta boundary, and it immediately rejected the core-side fix as well: it had split one sentence into two segments and put the held prefix before the prose it follows.Security. The artifact preview’s
<meta>CSP was inserted relative to the first<head>the model’s markup declared — and a meta policy governs only what follows it, so a<script>placed before that tag ran uncontained. Reproduced in Firefox, WebKit and csp-attribute-less Chromium; since thecspiframe attribute is Chromium-only, this meta is the only containment those engines get. Three branches collapse to one: always first.AparteToolRenderer.rendernow returnsstring | HTMLElementlike its sibling, and both it and the guide say thattoolCall.inputis model-chosen. And the primary backend-handler snippet no longer satisfies the mandatoryauthorizegate withBoolean(req.headers.get('cookie')), which authenticates nothing.Migration.
getHostHandlers()returnsRequired<AparteHostHandlersConfig>— four fields,artifactRehydrateincluded.AparteToolRenderer.renderwidened, so existing string renderers keep working. The page-global config moved to a versionedSymbol.forkey: two copies of@aparte/coreon one page now get one global each instead of sharing an object across whichinstanceofis false. Theshikiandmarkedpeer ranges narrowed to the majors this repo tests against (^4and^18) — an over-narrow peer is a warning you can override, an over-wide one is a lie.Six of the twenty MAJORs were defects in the guards themselves, which is the part worth reading twice. Seven gate steps ran in no CI workflow, five of them guards that bite — and the only place their names appeared under
.github/was a comment narrating a previous audit finding the same thing.check:gate-in-cinow diffs the workflow against the gate chain.check-event-mapwas blind to object-shorthanddetail, exempting the ten most important events.check-doc-snippetswaived every diagnostic in a fence containing one unresolved name — 44 of 118 fences, hiding twofor awaitSyntaxErrors in the branching guide.check-bundle-entriesread a re-export shim and skipped the chunk where core lives, and could not have seen an inlined dependency at all, so it gained an assertion that core’s manifest declares none.check-export-mentionscould not see type-only exports; measured once it could, 141 public exports were named on no page, now on a per-package ratchet that a new component cannot raise.@aparte/core,@aparte/engine,@aparte/plugin-marked,@aparte/plugin-model-selector,@aparte/plugin-shiki,@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
d3e482c: The question panel: right chat, readable schema, replaceable field
Eleven defects in the elicitation surface — the panel a tool puts up when it has to ask the user something. They survived four from-scratch audits for one reason, and it is the most useful thing in this release: no tool ever reached the model, so this surface was never executed. Not badly audited. Never run. Its unit tests pinned the shape it had and said nothing about which chat anything belonged to, what the model was asked to fill in, or what a screen reader would hear.
One person with a local model broke it in four places in twenty minutes.
Which chat a question belongs to.
<aparte-elicitation>could mount its panel in ANOTHER chat’s composer: it walked up looking for one and fell back todocument.querySelector. Removing that fallback was not enough — the walk itself reached<body>, where aquerySelectorsearches the whole document, so it found the other chat’s composer by a longer route. The walk now stops at the chat boundary and finding nothing cancels with a warning that names the fix. A Stop in one chat also cancelled the question another was waiting on, telling that chat’s model the user had refused something they were still reading — the twowindowlisteners had no instance filter at all. And in RAW core the composer could not identify itself either (all four wrappers settarget; hand-written markup does not), so one chat’s Stop tore down the other’s open panel while its tool call kept waiting.What the model is asked to fill in. A question with no options was schema-VALID:
optionswas neither required nor given aminItems, and the 2–6 range lived in the system prompt as prose. A local model duly sent two questions with no options, and the panel rendered a radio list whose only entry was “Other…” — a text box wearing the costume of a choice.optionsis now required withminItems: 2, and a model that ignores that gets an honest labelled text field instead of an emptyenum. The question text also stopped being the object property KEY: two identically-worded questions used to collapse into one field, and the field was labelled only because the panel falls back to printing the key. Stable keys now, the text as the field’stitle, and a label map so the model still reads “question → answer”.Who decides the UX.
allow_otheris out of the model’s schema and becomessetElicitationOptions({ allowOther })on the config. The model describes the question; the host owns the surface. Defaulttrue, so nothing a user sees changes — only who gets to say so. A model still sending it is ignored, so no existing call breaks. A field of a schema you build yourself can still setallowOther, and it wins.What the user sees. The composer kept offering the attachment picker through an entire elicitation — there is nowhere for a file to go while you are answering a question. Declared now with
data-panel-active+ CSS instead of an inlinestyle.displaythat clobbered a consumer’s own value. Groups of choices are named by the question they answer (role="radiogroup"/group+aria-labelledby): a screen reader used to announce “Chromium, radio button, 1 of 2” with no question attached. Seven strings that were hardcoded English — “Other…”, its placeholder and accessible name, “Skip”, “Yes”, “No”, “Your answer” — are optional locale keys with per-key fallback, plus the French. And the panel’s CSS moved out of a JS-injected<style>into the stylesheet with fifteen--aparte-elic-*tokens: it was the one surface that could not be themed, its variables were absent from the generated reference, and the injection was never re-created if anything removed it.How several questions are asked. A form of two or more questions put them all in one box — a shape inherited from MCP elicitation without being examined. MCP describes a form for collecting structured data; asking a person two different questions in the middle of a conversation is not that, and no product does it by stacking. Several questions are now asked ONE AT A TIME, with a chip per question that is also how you go back. Each field takes a short
headerfor that chip (the tool schema asks the model for two or three words) and falls back to the question’s position rather than truncating a sentence. The protocol is untouched: the answer is still one object with every key, and the composer’s send button still means submit.layout: 'stacked'keeps the form case, which is real — it was just never the right default.The composer’s one button carries the progression: a chevron while questions remain, a check on the last one, and the panel is what knows which. That is why there is no “Next” button — the composer already has a button, in a place the user knows, and it already changes meaning between sending and stopping. Adding a second row for a Next made the panel taller and made it change height when that row went; folding the meaning into the existing button removed both problems and the button now says what it does.
And the escape from the whole request sits in the panel’s CORNER, not in a row beside the button that advances through the form: adjacency promised “skip this question” while it declines everything. Position, not decoration — which is also why the reference implementations put theirs in a corner.
What a consumer can replace.
setElicitationFieldRendererrenders one field while the panel keeps everything around it. It returns a control rather thanstring | HTMLElementbecause a field must hand back a value, and the schema vocabulary is now a stated contract — three field kinds plus the object form, closed, with a test pinning the count so it cannot grow quietly.Migration.
allow_otheris ignored rather than rejected. The panel’s pixels can move if you were overriding its rules by selector — that is the trade for being able to override them by token. An<aparte-elicitation>mounted outside any chat now cancels with a warning instead of borrowing the first composer on the page.AparteLocalegains seven OPTIONAL keys, so an existing locale package keeps compiling and keeps rendering English.Twenty-six new unit tests and five browser tests — the first browser coverage this surface has ever had. Every fix has its sabotage, and one of them refuted a claim of mine before it shipped: the new axe scan does NOT catch an unnamed radio group, so the comment saying it would is corrected in place and the unit tests are named as the real guard.
@aparte/core,@aparte/plugin-ask-user,@aparte/locale-fr -
1603015: No tool ever reached the model, and three smaller things a first test session found
All four of these came out of one person sitting down with the examples and a local LM Studio, which found in twenty minutes what four from-scratch audits had not. The pattern is worth naming: an audit reads the code, a user runs it.
A registered tool was never sent.
AparteClientgates the request’stoolsarray ongetCurrentModel()?.capabilities?.includes('function_calling'). Three facts made that gate permanently closed on the documented primary path:getCurrentModel()readprovider.getModels()— the synchronous, hand-declared list — which every preset of@aparte/provider-openai-compatleaves empty because a compat endpoint’s list only exists after aGET /models;fetchModels()never wrote its result anywhere the resolver could see; and it declared only['streaming']. SogetTools()held the tool the app had registered andtools: []went on the wire. The model then answered, correctly, that it had no such tool — which is exactly what a tester saw, with no error and no warning anywhere. The whole tools guide,needsApproval, human-in-the-loop approval and@aparte/plugin-ask-userwere inert.Three changes, each with the reasoning where it lives.
AparteConfigcaches whatrefreshProviderModels()brings back andgetCurrentModel()consults it before the static list.openai-compatdeclaresfunction_calling, because atoolsarray is a property of the wire format it implements, not a guess about the model —/modelsreturns{id, object, owned_by}and will never say otherwise, so waiting for it to declare the capability means never declaring it. And the gate now asks whether the model said it CANNOT rather than whether it said it can: a model that declares its capabilities and omits function calling is still honoured, but an unknown model — the common case — no longer turns an explicitregisterToolinto a silent no-op. Over-sending means a model that cannot call a tool does not call one; under-sending was silent and total. Two end-to-end tests that had been parked on this decision are now running.requireModelSelectionis enforced by the thing that runs the turn. It was drawn byaparte-composer— greying itself, refusingsubmit()— and enforced nowhere else, so any other route to anaparte-sendwalked past it: a suggestion chip, a “try this prompt” button, a host dispatching the event itself. The turn then ran withconfig.defaultModel || '', an empty model id on the wire. Reported from an example, where the chips above the composer stay clickable while the composer is visibly greyed out waiting for its model list. The client now refuses such a send and says why, because the developer is who can fix it — an app that gates should disable its own affordances too.The model selector’s dropdown was ordered by a race. It fetches every provider’s
/modelsin parallel and pushed each result as it arrived, so the order — and therefore whatauto-selectlands on — was decided by whichever endpoint answered first. A cloud provider on a CDN beats a local server that has to wake up, which means an app registering[local, local, cloud]could land on the paid one, and on a different one after a reload. The list is indexed by registration order now:auto-selectdocuments itself as “the first model”, and first has to mean first.And the guide that described the old gate said tools are sent “only when the selected model’s
capabilitiesincludefunction_calling”, which was true and is the sentence that made the behaviour look intended rather than broken.@aparte/core,@aparte/provider-openai-compat,@aparte/plugin-model-selector -
950261d: The
<artifact>XML streamer is a file, and its twin no longer disagrees with it@aparte/coreand@aparte/engineeach carry a hand-maintained copy of the same streaming<artifact>state machine — core cannot import engine’s, because engine peer-depends on core. Keeping two copies in step is the whole contract, and until now core’s half had no name: it was a private method plus a nested block inside a 2324-line class, so the two files cited each other by line number. Four of six of those citations had rotted onto unrelated code.:1658-1669, sold as “the finalize block”, was a tool handler’sAbortController;:1034-1042, sold as “_streamLoop’s leading writes”, was_handleSendresolving auth. One of the wrong ones was published in the API reference.Core’s half now lives in
client/xml-artifact-feed.ts, holding both halves the way engine’s file does —feedXmlArtifactDeltaandfinalizeXmlArtifact. It moved without a semantic change: it dereferencesthiszero times, because the state it mutates was always owned by its caller. Every citation between the two files is now a name, and a new gate guard (check:cross-refs) refuses a comment that cites code by line number at all.Bug fixed, found by the pairing. A stream that ended on a held partial tag —
… <arti, then nothing — silently dropped those characters. The feeder holds such a suffix on purpose (without it, a tag split across deltas loses the artifact’s whole lifecycle), and engine’sfinalize()has always handed the held text back as chat text. Core’s finalize only ever handled thein-artifactcase. Reachable with nothing unusual: any truncated reply whose last delta happens to end on<,<a, …<artifac.No API change:
AparteClientbehaves identically apart from that fix, and the new module’s exports are not re-exported from the package barrel.@aparte/core,@aparte/engine -
c87d2b2:
@aparte/plugin-ask-questionis now@aparte/plugin-ask-user, and the tool isask_userA rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.
There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (
elicitation/create), and ours already matched:requestUserInput,AparteElicitation*,<aparte-elicitation>. The tool level has no formal standard but a clear convention, and it isask_user: Claude Code’sAskUserQuestion,datasette-agent’sask_user(),pi-ask-user,ask-user-questions-mcp.ask_questionwas ours alone.What changed
- the package:
@aparte/plugin-ask-question→@aparte/plugin-ask-user - the tool the model is offered:
ask_question→ask_user - the element alias:
<aparte-ask-question>→<aparte-ask-user>, classAparteAskQuestion→AparteAskUser - the exports:
askQuestionTool/askQuestionHandler/setupAskQuestion→askUserTool/askUserHandler/setupAskUser, andAskQuestionOption/Item/Detail→AskUser*
What did NOT change, deliberately. The receipt keeps its names —
questionReceiptRenderer,QuestionReceiptSegment, and the'question-receipt'segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.
A model that keeps calling
ask_questiongets no tool by that name, which surfaces as an unknown-tool error rather than silence.@aparte/plugin-ask-user - the package:
Patch Changes
-
c87d2b2:
@aparte/plugin-ask-questionis now@aparte/plugin-ask-user, and the tool isask_userA rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.
There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (
elicitation/create), and ours already matched:requestUserInput,AparteElicitation*,<aparte-elicitation>. The tool level has no formal standard but a clear convention, and it isask_user: Claude Code’sAskUserQuestion,datasette-agent’sask_user(),pi-ask-user,ask-user-questions-mcp.ask_questionwas ours alone.What changed
- the package:
@aparte/plugin-ask-question→@aparte/plugin-ask-user - the tool the model is offered:
ask_question→ask_user - the element alias:
<aparte-ask-question>→<aparte-ask-user>, classAparteAskQuestion→AparteAskUser - the exports:
askQuestionTool/askQuestionHandler/setupAskQuestion→askUserTool/askUserHandler/setupAskUser, andAskQuestionOption/Item/Detail→AskUser*
What did NOT change, deliberately. The receipt keeps its names —
questionReceiptRenderer,QuestionReceiptSegment, and the'question-receipt'segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.
A model that keeps calling
ask_questiongets no tool by that name, which surfaces as an unknown-tool error rather than silence.@aparte/core - the package: