Mobile Engines
One interface, three engines, a conformance suite, and how the in-process engine persists a turn.
The mobile app picks an engine from a factory that is handed the store engines write through, so the in-process engine records a turn to the same session the chat list reads. Everything above the seam is written against AgentEnginePort and nothing else.
import { createRemoteHttpEngine } from "praisonai-mobile/engines/remote-http";
const engine = createRemoteHttpEngine({ baseUrl: "http://127.0.0.1:8765", http });
for await (const event of engine.run(request, signal)) {
if (event.type === "delta") process.stdout.write(event.text);
}Quick Start
- RegistryDeps requires a persistence
persistenceis required. It is passed only into the in-process engine’s factory.ts export interface RegistryDeps { readonly settings: SettingsFacade; readonly http: HttpPort; readonly createInProcess?: (persistence: RunPersistence) => AgentEnginePort | Promise<AgentEnginePort>; readonly persistence: RunPersistence; // required } - An EngineChoice can carry a probe
createbuilds the engine; the optionalprobeanswers whether the thing it talks to is ready. The in-process engine has no remote to probe, so it supplies none.ts export interface EngineChoice { readonly id: string; readonly create: () => AgentEnginePort | Promise<AgentEnginePort>; /** Is the thing this engine talks to actually ready to answer? * Optional โ the in-process engine has no remote to probe. When * present it runs at selection, so a remote that answers 200 with * `{"ok": false}` is refused at boot with a name rather than * surfacing as a transport error mid-turn. */ readonly probe?: () => Promise<Readiness>; } - selectEngine returns notReady on a retryable probe
The success branch carries an optional
notReady, set only for a retryable unreadiness. A permanent unreadiness (version_mismatch) takes the failure branch and disposes the engine.ts export type EngineSelection = | { readonly ok: true; readonly engine: AgentEnginePort; /** The engine was selected but is not answering yet. Set only for a * RETRYABLE unreadiness -- a permanent one refuses instead. */ readonly notReady?: { readonly reason: string; readonly detail: string }; } | { readonly ok: false; readonly reason: "unknown_engine" | "protocol_mismatch" | Extract<Readiness, { ready: false }>["reason"]; readonly detail: string; /** Only a probe failure sets this: a transport error may resolve on a * retry, a version mismatch never will. */ readonly retryable?: boolean; }; - enginesFor wires a probe and passes persistence to in-process only
The remote-http choice supplies a
probe; the remote engine deliberately does not receivepersistence.baseUrlis a resolver, not a captured string โ read at the moment it is used, so a Settings edit is live on the next/chatand/health.ts export function enginesFor(deps: RegistryDeps): readonly EngineChoice[] { // A RESOLVER, not a captured string: read at the moment it's used, so a // Settings edit is live on the next /chat and /health. const baseUrl = (): string => stringSetting(deps.settings, "baseUrl", "http://127.0.0.1:8765").replace(/\/+$/, ""); const choices: EngineChoice[] = [ { id: ENGINE_REMOTE_HTTP, create: () => createRemoteHttpEngine({ baseUrl, http: deps.http, id: ENGINE_REMOTE_HTTP }), // Called, not captured โ probing the address the user just replaced // reports a failure about a machine nobody is talking to. probe: () => probeHealth(deps.http, baseUrl()), }, ]; // in-process engine (no probe โ it IS the device) if (deps.createInProcess !== undefined) { const build = deps.createInProcess; choices.push({ id: ENGINE_PRAISONAI_TS, create: () => build(deps.persistence) }); } return choices; }The resolver is passed to
createRemoteHttpEngineand the probe isprobeHealth(deps.http, baseUrl())โ invoked, not captured. Pinned by the resolver tests inregistry.test.tsandengine.test.ts. - Build engines from the session
AppDeps.enginesis a factory(persistence) => EngineChoice[]. The composition root builds it from the session, so an engine cannot exist without the store it writes through. - The shipping composition root wires it
appEnginessuppliescreateInProcesstoenginesFor, so the in-process engine is on offer. The remote engine stays first, so the default keeps working with nothing configured.ts import { appEngines } from "praisonai-mobile/app/main"; const choices = appEngines({ settings, http: platform.http, persistence, onIgnored: (reason, detail) => sink.note(reason, detail), }); // choices.map(c => c.id) โ ["remote-http", "praisonai-ts"]Pinned by
"the real composition root offers the in-process engine"inapp/src/main.test.ts. - Run a turn
ts for await (const event of engine.run(request, signal)) { console.log(event.type); }runreturns anAsyncIterable, sofor awaitgives free backpressure and one cancellation path. Breaking out of thefor awaitcancels the underlying response stream in afinally(await reader.cancel()), so the engine stops generating and the socket is released. Before #4579 this was silently untrue forremote-httpโ thefinallylacked the cancel, so leaving the loop left the model billing. - Answer an approval
ts const recorded = await engine.decide(approvalId, "allow");Approval is a reverse channel while the stream is live, so it is a method, not an event.
- Surface refused frames with onIgnored
ts const engine = createRemoteHttpEngine({ baseUrl, http, onIgnored: (reason, detail) => sink.note(reason, detail), });Without
onIgnored, decoder refusals are invisible โ a truncated answer reported as a clean success. The composition root wires it via theDropSink; anyone hand-constructing the engine must pass it explicitly.
Options On remote-http
RemoteHttpOptions carries the request target and the refusal callback.
| Option | Type | Purpose | |
|---|---|---|---|
baseUrl | `string \ | (() => string)` | Engine base URL, or a resolver read per request. A plain string still works and still means “this one, forever”; a resolver is re-read on each /chat, /cancel, /approve, and /health, so a Settings edit is live on the next message. Any number of trailing slashes are stripped per resolution before the path is appended, so http://host, http://host/, http://host//, and http://host/// all POST to http://host/chat. Pinned by "a base URL with several trailing slashes still builds one clean path". |
http | HttpPort | All I/O goes through this port โ no fetch in the engine. | |
token | string | Optional bearer token; loopback is unauthenticated, off-device must not be. | |
onIgnored | (reason, detail) => void | Called for every frame the decoder refused. Wired to dropSink.note by the composition root. | |
id | string | Optional engine id; defaults to "remote-http". |
ControllerDeps.dropSink is the other end of the same seam: what the engine refuses becomes a dropped row on the turn it belonged to. See Dropped Events.
The address is resolved per request
The engine is built once at boot and held for the session, so a captured baseUrl string strands a phone that cannot reach its default engine โ the only recovery is force-quitting the app. A resolver reads the address at the moment it is used, so the next /chat and /health go wherever Settings says right now.
// engine.ts โ resolved per call, never captured.
const base = (): string =>
(typeof options.baseUrl === "string" ? options.baseUrl : options.baseUrl()).replace(/\/+$/, "");Trailing slashes are stripped per resolution, not at the caller: the resolver’s answer is not seen until the string is used, and ${base}/chat against http://host/ produces //chat โ which some servers 404 and others redirect, losing the POST body.
Rebuilding the whole engine on a Settings change was the alternative and a much larger one: createRunController takes the engine at construction with no seam to swap it, so following the setting that way means rebuilding the controller mid-session and dropping the turn in flight. A resolver costs one call per request and no signature above it.
Cancel and Approve are pinned to the origin the run was born at
A resolver means base() can move between calls โ that is the point โ but a run’s /cancel and an approval’s /approve are addressed by an id the originating engine minted. Two maps pin the control requests to where the run started:
| Map | Records | Read by |
|---|---|---|
runBase: Map<runId, string> | the resolved origin at the start of run(...) | cancel(runId) โ runBase.get(runId) ?? base() |
approvalBase: Map<approvalId, string> | the origin when an approval_request arrives | decide(approvalId, choice) โ approvalBase.get(approvalId) ?? base() |
The origin is resolved once, at the top of run(...), and remembered for that run’s control requests. The live resolver is only a fallback for an id this instance never saw โ a resumed app that reconnected.
sequenceDiagram
participant User
participant Engine
participant Old as Engine A (run origin)
participant New as Engine B (edited address)
User->>Engine: run(runId) โ origin pinned to A
User->>Engine: Settings edit โ base() now B
User->>Engine: cancel(runId)
Engine->>Old: /cancel to A (runBase.get)
Old-->>Engine: ok:true โ run stopped
Note over New: B never issued runId; a /cancel there 404sWithout pinning, a Settings edit mid-turn would send /cancel to an engine that never issued the runId, postOk would 404, cancel would return false, and controller.ts would leave the original engine generating (and billing) while the user believes they hit Stop.
Readiness body rules
After the 200 passes, classify in engines/src/remote-http/readiness.ts judges the body by strict identity, not by truthiness. The healthy predicate is body.ok === true โ nothing else counts as healthy.
| Body after HTTP 200 | Verdict |
|---|---|
{ok: true} (optionally with a valid version) | healthy โ ready: true |
{ok: "yes"}, {ok: 1}, {ok: "true"}, {ok: "false"}, {ok: {}}, {ok: []} | unhealthy โ even when a valid version is present |
ok missing entirely, or ok: false / null / 0 | unhealthy |
A missing ok must never be mistaken for a present healthy one: {ok: "true"} (a string) and a body with no ok at all are both refused. The paired positive case โ body.ok === true โ is what stops the predicate degenerating to “refuse everything”.
Probe timeout
The probe runs before the app mounts, so a request or body read that never completes must abort on a deadline rather than pin createApp on a blank screen.
export const PROBE_TIMEOUT_MS = 5000;
// Bounded by timeoutMs; a request or body read that never completes
// aborts on the deadline and classifies as retryable transport.
await probeHealth(http, baseUrl, token, PROBE_TIMEOUT_MS);A single AbortController covers both the request and the body read, so an endpoint that accepts the socket but never sends the response โ a still-binding engine, a black-hole proxy โ aborts on the deadline. A timeout classifies as retryable transport, exactly the case worth polling.
Order of checks in selectEngine
A probe opens a socket, so it must not run for an engine this build cannot even speak to. The checks run in order, cheapest first.
| Step | Check | Outcome |
|---|---|---|
| 1 | Is id known? | Fails: unknown_engine |
| 2 | Does protocolVersion match? | Fails: protocol_mismatch |
| 3a | Probe returns ready: false, retryable: false | Fails: version_mismatch. Engine disposed. |
| 3b | Probe returns ready: false, retryable: true | Succeeds with notReady attached. Engine kept โ it’s the one the app will use. |
That step 3 does not run when step 2 fails is pinned by "selectEngine runs the probe only after the protocol check passes". The split at step 3 mirrors readiness.retryable: a permanent unreadiness disposes and refuses, a retryable one keeps the engine and returns ok: true.
First-Launch Default
defaultEngineIdFor(kind: Platform["kind"]) returns the engineId used only on the very first launch, when settings name no engine โ and a persisted engineId always wins over it.
The first-launch default is not the only engine the app ever uses: a persisted engineId still wins, and the Settings Screen is what persists a change to it.
The function splits by platform: "tauri" defaults to the in-process engine, "web" to remote. Pinned by "a device's first-launch default is the in-process engine; the web's is remote" in app/src/main.test.ts.
import { defaultEngineIdFor } from "praisonai-mobile/app/main";
defaultEngineIdFor("tauri"); // "praisonai-ts" โ a device runs in-process
defaultEngineIdFor("web"); // "remote-http" โ a browser tab has a server
// mount() calls it once, at boot, when no engineId is persisted.
// A persisted engineId always wins over this default.A device defaults in-process because a phone cannot reach http://127.0.0.1:8765, and the cleartext loopback address is refused by iOS ATS and Android besides โ so a remote default was a first prompt that failed with a status code. The in-process engine now ships as a lazily-fetched chunk beside app.js (see How the engine reaches the app), so it is the one choice that works with nothing configured โ up to the model itself, which needs an OpenAI key pasted into Settings (see API Keys and How the in-process engine authenticates).
The web keeps the remote engine: a browser tab has a server to talk to, and no reason to fetch ~900 kB of engine before first paint. See Web App (PWA) for how the web target boots, installs, and works offline.
The seam is a function a test drives directly rather than an expression asserted to appear in mount (mirrors stopNotice and appEngines, the house rule). Defaulting a device to in-process removes the intermediate hop over http://127.0.0.1:8765 and the iOS ATS / Android usesCleartextTraffic exception on the default path (see Errors & Recovery).
Who Persists
Two engines, two owners of the write.
| Engine | Receives persistence | Who owns the write |
|---|---|---|
In-process (praisonai-ts) | Yes | The mobile session on this device |
Remote HTTP (remote-http) | No | The server it talks to |
The remote engine deliberately does not take persistence: the server it connects to owns the write and is the only thing that can report authoritative indices for its own store.
Where a Completed Turn Is Written
Each engine owns its own write, and reports end.userIndex from the store it wrote to โ and reads the next turn’s history back from the same store.
| Engine | Writes the turn to | Reports end.userIndex from | Reads history for the next turn from |
|---|---|---|---|
praisonai-ts (in-process) | The mobile app’s session store, via the RunPersistence handed in at construction | The store it just wrote to | The mobile app’s session store, via ConversationHistory (historyFor(session)), bounded by HISTORY_CHAR_BUDGET |
remote-http | The desktop/remote server it POSTs the run to | The server’s own store (reported over the protocol) | The remote server (server keys by chat_id) โ client-side history deliberately not passed, to avoid doubled turns |
The read side is symmetric with the write: the in-process engine is handed both RunPersistence and ConversationHistory, both projected from the same session; the remote engine takes neither, because the server it talks to owns both. See History & Reopen โ The model actually gets the conversation.
The in-process engine records the turn and reports the indices it actually wrote, which is what makes end.userIndex real:
const indices = await options.persistence.record(request, answer);
yield {
type: "end",
msgId,
userIndex: indices?.userIndex ?? null,
assistantIndex: indices?.assistantIndex ?? null,
versions: indices?.versions ?? 1,
active: indices?.active ?? 0,
};What a Recorded Turn Returns
session.record(prompt, answer) writes the user message and the answer together, then returns the indices into the persisted array.
| Field | Meaning |
|---|---|
userIndex | Position of the user message on disk |
assistantIndex | Position of the answer on disk |
versions | Number of stored versions |
active | Which version is shown |
A null return means the save failed โ the turn is on screen but not on disk.
The transcript reads end.userIndex through the same isPersisted predicate that decides whether Fork and Delete are offered โ not a second copy of the rule. An index means the user row is stored; no index means it is unstored and carries the “Not saved” caveat. See Transcript User Row โ Three storage states.
Persisted Answer Text
The in-process engine (praisonai-ts) writes exactly one string as the assistant answer per turn. Which string depends on whether the turn produced a finish event.
Turn saw a finish event? | What gets persisted |
|---|---|
| Yes | finish.text โ the streamed deltas are discarded for the write |
| No | Concatenation of every delta.text in order |
Why replace rather than append?
- Upstream may normalise the final text โ a typo fix or whitespace tidy-up. Persisting the concatenation would store text the model did not finally say.
- A non-streaming provider emits no deltas, only
finish. Persisting only the accumulation would drop the answer entirely.
if (event.type === "text") {
if (event.delta === "") continue;
answer += event.delta; // accumulate while streaming
yield { type: "delta", msgId, text: event.delta };
} else if (event.type === "finish") {
answer = event.text; // REPLACES the accumulation
}The Port
AgentEnginePort is the entire agent-framework coupling.
| Member | Type | Purpose |
|---|---|---|
id | string | Stable id, written into persisted chats. |
protocolVersion | number | Checked once at boot; a mismatch fails boot with a name. |
capabilities | EngineCapabilities | What the engine can report, checked before the first token. |
run(request, signal) | `AsyncIterable |
| One turn: start first, exactly one terminal event last. | | decide(approvalId, choice) | Promise<boolean> | Answer an approval; false for an unknown id, a decided id, or a non-200 response from the engine. | | cancel(runId) | Promise<boolean> | Stop a run; false when it was not live or when the engine returns a non-200. | | dispose() | Promise<void>` | Release clients and sockets. Idempotent. |
The Three Engines
Three implementations pass the same conformance suite, which is what makes “swappable” a fact.
| Engine | Where | Talks to |
|---|---|---|
praisonai-ts | engines/src/praisonai-ts | The agent loop, in-process on the device. |
remote-http | engines/src/remote-http | A PraisonAI engine over HTTP + SSE. |
| scripted fake | testing/ | Nothing โ replays canned events for tests. |
How the engine reaches the app
The in-process engine loads praisonai’s Agent through a single literal dynamic import, in engines/src/praisonai-ts/load-agent.ts:
export async function loadPraisonAgent(): Promise<PraisonAgentModule> {
try {
const mod = (await import("praisonai/mobile")) as unknown as { Agent: PraisonAgentModule };
return mod.Agent;
} catch (cause) {
throw new Error(
"the in-process engine is unavailable in this build: praisonai could not be loaded",
{ cause },
);
}
}Three facts make this the shipped seam:
- The specifier is a literal. esbuild can only put a module in its own lazily-fetched chunk if it can see which module it is; the runtime-computed path this used to be was opaque, so the engine either came along eagerly or, as an external, not at all. The literal is what turns it into a chunk.
praisonai/mobile, notpraisonai. The default entry re-exports the CLI, the MCP server and the knowledge store, whose Node builtins are import-time fatal in a webview./mobileis the package’s webview-safe allowlist entry.- One file may name it.
tools/boundaries.jsonallows the stringpraisonaionly fromengines/src/praisonai-tsโ the agent-framework seam. Making the import literal did not cost that rule;load-agent.tsis the one place it lives.
The shell and lazy split
The build emits two populations of chunk, paid for at different moments, so tools/bundle.mjs carries two budgets:
| Budget | Covers | Ceiling | Measured at the chrome58 floor |
|---|---|---|---|
SHELL_BUDGET_BYTES | the entry chunk + its static import graph โ fetched and parsed before the first frame, on every cold start | 400 kB | 87.7 kB (app.js + esbuild’s interop helper) |
LAZY_BUDGET_BYTES | everything reachable only through import() โ the engine and its remaining provider stack | 1000 kB | 912.6 kB across 17 chunks (the engine plus zod, ai, openai, and praisonai) plus ~100 kB of chrome58 lowering |
They are two budgets, not one in disguise: bundle.test.mjs proves each can fail on its own, and depgraph.test.mjs pins both values so neither can be widened without a failing test.
The 1000 kB ceiling is not a round number โ it is re-derived on each raise from the current measured bundle. The rule is a floor (measured 912.6 kB + a 30 kB drift band) and a lid (measured + 100 kB, the smallest provider on the measured list); at or above the lid a new provider can no longer trip the gate, which is the one thing the constant exists to force. 1000 kB sits 57 kB above the floor and 12 kB under the lid โ two independent derivations land there. See tools/bundle.mjs for the full derivation, re-derived from the measured bundle after the #4874 and #4882 reclaims in PR #4903. Re-measure the per-chunk breakdown with node src/praisonai-mobile/tools/bundle.mjs on main before quoting per-package byte counts โ the earlier @ai-sdk/* breakdown is stale after #4874.
Consumer-first bare-import resolution
bundle.mjs resolves every bare import from this package first, from the importer second. Peers are the consumer’s to provide: praisonai lists ai and @ai-sdk/* as optional peers, this package declares them, and a registry install hoists them beside praisonai. Through the file: link (below) Node’s real-path resolution looks in ../praisonai-ts/node_modules instead and cannot see them โ @ai-sdk/cohere, which praisonai imports and never declares, came back unresolved, measured. Consumer-first is the layout a registry install produces without depending on hoisting, and keeps zod and @ai-sdk/provider-utils to one copy. praisonai’s own dependencies (openai, and the rest) are not here and fall through to its node_modules, as they should.
Development consumes praisonai through a file link
package.json links praisonai as file:../praisonai-ts, not a pinned version, so the engine the app ships is the one from the same monorepo commit. The fix that makes the engine buildable below chrome89 โ praisonai-ts #4720, which removed a top-level await from praisonai-ts’s esm shim on the mobile graph โ is in the sibling package and reaches the app through this link. .github/workflows/mobile.yml watches src/praisonai-ts/{src,scripts}/** and package.json, and every job builds praisonai-ts (npm install --legacy-peer-deps && npm run build) before npm ci in the mobile package. Editing praisonai-ts and mobile in the same PR is therefore one build, not a publish-and-bump.
How the in-process engine authenticates
The gap is closed as of PR #4792: the registry now declares an openaiApiKey secret setting, and createInProcessEngine reads it through apiKeyFor(secrets, settings.defs()) on every turn.
const apiKey = await apiKeyFor(secrets, settings.defs());
return new Agent({
instructions: "You are a helpful assistant.",
llm: settingString("model", "gpt-4o-mini"),
// Omitted, not passed as "" or null, when unset: upstream treats a falsy
// apiKey as "fall back to the environment", and a phone has none.
...(apiKey === null ? {} : { apiKey }),
});Reading per turn โ not once at construction โ is the same lesson enginesFor learned with baseUrl: the engine is built once and held for the session, so a key pasted into Settings works on the very next message rather than after a force-quit. The key is omitted from the agent config when unset rather than passed as "" or null, because upstream treats a falsy value as “fall back to the environment” and a phone has no environment.
appEngines now takes secrets: SecretsPort as a required dependency, so a composition that forgets it fails typecheck rather than building an engine with no credential. See the API Keys page for the user-facing paste-and-send flow.
Conformance
Passing engines/src/conformance.ts is the definition of implementing the seam.
export interface EngineHarness {
readonly name: string;
create(scenario: ScenarioName): Promise<AgentEnginePort>;
readonly unsupported?: Partial<Record<ScenarioName, string>>;
}The suite also asserts the negative direction: an engine declaring approvals: false must never emit an approval_request. Every unsupported scenario is printed on each run, so a contract that quietly shrinks is visible rather than silently green.
The suite drives each guarantee against a deliberately broken run. The shipped break modes are:
| break mode | breaks the run by |
|---|---|
no_start | Skipping its opening event. |
start_only | Opening and then simply stopping, with no terminal event at all. |
tool_failure_as_ok | Reporting a failed tool as ok. |
empty_is_fine | Reporting an empty stream as an empty answer instead of an error. |
decide_always_true | Reporting an unknown approval as accepted. |
abort_ignored | Never consulting the abort signal, so a cancelled run streams to completion. |
Both no_start and start_only are caught by the same case “a run emits start first and exactly one terminal event last” โ one missing the opening event, the other missing the terminal one. start_only proved the case’s events.length >= 2 assertion is redundant rather than unguarded: weakening it to >= 1 still catches a run that opens and never ends.
When To Pick Which Engine
Common Patterns
An engine whose prerequisites are absent is omitted, not offered and then failed.
// createInProcess is optional: without it, only remote-http is listed.
if (deps.createInProcess !== undefined) {
choices.push({ id: ENGINE_PRAISONAI_TS, create: () => build(deps.persistence) });
}selectEngine names the available engines when an id is unknown, so a missing engine is an honest message rather than a crash.
const outcome = await selectEngine("nope", choices);
// outcome.detail names what IS available, e.g. "remote-http, praisonai-ts"Best Practices
Never bypass the factory
Obtaining the engine list requires the persistence argument. Do not construct engines directly โ the type makes the wiring impossible to forget.
Treat persistence as required, not optional
RegistryDeps.persistence has no default. The in-process engine’s end.userIndex is only real because it records through this store โ without it, the turn is on screen and not on disk.
Do not persist remote-http into the local session
The remote server owns its own store and reports its own indices. Writing a second copy locally reintroduces divergence between screen position and disk position.
Do not switch chats from the request
The session already knows which conversation is open. Taking direction from the run request would let an in-flight turn write into whichever chat the user has since navigated to.
Read null as 'not on disk'
When record returns null the write failed. Null travels to the UI as “do not offer Fork or Delete”, because those affordances would address a message that does not exist. Index 0 is valid, so a falsy check is a trap.
Read capabilities before rendering
capabilities is a property, not a method, so the UI decides what to render before the first token arrives.
Never fake an unsupported scenario
Declaring a gap in the unsupported map is honest; faking it hides a defect the conformance suite exists to catch.
Return false, never a lie
decide and cancel return false for an unknown id, a decided id, and any non-200 response from the engine โ reporting success for a request the engine did not accept is a lie the UI cannot detect. postOk checks if (response.status !== 200) return false;, pinned by "a decision or cancel the engine did NOT accept is reported as refused" over [202, 400, 401, 403, 500, 502].
Break out of the iterator to release the socket
The consumer’s break, return, or exception out of the for await loop is the cancellation channel โ there is no separate close(). The engine’s finally calls await reader.cancel(), so leaving the stream releases the reader and the model stops billing. The regression "leaving the stream early releases the socket" asserts the underlying ReadableStream‘s cancel() fires when the consumer breaks after the first delta. See Stop is idempotent and race-safe.
A failed engine-chunk fetch fails RECOVERABLY, not opaquely
The engine reaches praisonai through a lazily-fetched chunk, and a fetch can fail โ a flaky connection, a build that left the engine out, a hashed file the page no longer matches. When it does, the failure arrives as a single error event through the run loop โ a named row with the standard Retry affordance โ never as an unhandled rejection. create() itself succeeds because it wires the factory, not the chunk; the loadPraisonAgent guard turns the rejected import("praisonai/mobile") into that recoverable event. Pinned by "the in-process engine, when its chunk cannot load, fails RECOVERABLY" in app/src/main.test.ts, which injects a failing loader through the appEngines seam. The separate first-turn gap for want of a key is The known API-key gap.
A retryably unready engine boots the app, not the crash screen
A retryable probe failure (unhealthy, http_status, malformed, transport) returns ok: true with a notReady field โ the engine is kept and the app starts. Only version_mismatch refuses boot. On a phone with no desktop engine to reach, the old hard gate meant the app never opened and the user could not fix the address, because Settings lives inside the app that would not start. Waiting cannot fix a version mismatch, but it does fix a socket still binding. Pinned by the boot and selection tests in app/src/boot.test.ts and app/src/engines.ts. See Boot Failures.
A run given an already-aborted signal produces no answer
When the caller passes an AbortSignal whose .aborted is already true โ the dispose-then-new-run path, and Stop-immediately-followed-by-Send โ the engine aborts its own controller before subscribing, so no delta events are emitted and nothing is persisted. Before this fix, the code path attached addEventListener("abort") after the fact, which never fires for a signal that went down before the listener was attached; the run streamed to completion and persisted an answer the UI had already discarded. Pinned by "a run started with an ALREADY-aborted signal produces no answer", with "a run with a live signal still streams, so the abort test is not vacuous" as the companion. See Approvals & Cancellation for the Stop-then-Send route into the same signal.
Related
Boot order and the engines factory shape.
OverviewNative navigation and the retained chat screen.
Capabilities & GapsWhat each engine can and cannot report.
Shell & AdaptersThe keyboard snapshot and the pinch-zoom guard.
ProtocolThe 11 events every engine speaks.
Dropped EventsWhere a refused frame lands via onIgnored.
Boot FailuresWhere a retryable probe boots the app with a warning, and a permanent one refuses.
Settings ScreenWhere a user overrides the persisted engineId and baseUrl on-device.
Web App (PWA)Why the web target defaults to remote-http, and how it installs and works offline.