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.

ts
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);
}
Diagram

Engine Selection

๐Ÿ’พ Session

๐Ÿญ engines(persistence)

๐Ÿค– In-Process

๐ŸŒ Remote HTTP

Engine Registry

server-owned

๐Ÿงพ RegistryDeps

๐ŸŒ remote-http

๐Ÿ“ฑ praisonai-ts

๐Ÿ’พ RunPersistence

๐Ÿ“œ ConversationHistory

๐Ÿ–ฅ๏ธ remote server

Diagram

โš™๏ธ Run Controller

๐Ÿ”Œ AgentEnginePort

๐Ÿง  praisonai-ts

๐ŸŒ remote-http

๐Ÿงช scripted fake

Quick Start

  1. RegistryDeps requires a persistence

    persistence is 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
    }
  2. An EngineChoice can carry a probe

    create builds the engine; the optional probe answers 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>;
    }
  3. 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;
        };
  4. enginesFor wires a probe and passes persistence to in-process only

    The remote-http choice supplies a probe; the remote engine deliberately does not receive persistence. baseUrl is a resolver, not a captured string โ€” read at the moment it is used, so a Settings edit is live on the next /chat and /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 createRemoteHttpEngine and the probe is probeHealth(deps.http, baseUrl()) โ€” invoked, not captured. Pinned by the resolver tests in registry.test.ts and engine.test.ts.

  5. Build engines from the session

    AppDeps.engines is a factory (persistence) => EngineChoice[]. The composition root builds it from the session, so an engine cannot exist without the store it writes through.

  6. The shipping composition root wires it

    appEngines supplies createInProcess to enginesFor, 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" in app/src/main.test.ts.

  7. Run a turn
    ts
    for await (const event of engine.run(request, signal)) {
      console.log(event.type);
    }

    run returns an AsyncIterable, so for await gives free backpressure and one cancellation path. Breaking out of the for await cancels the underlying response stream in a finally (await reader.cancel()), so the engine stops generating and the socket is released. Before #4579 this was silently untrue for remote-http โ€” the finally lacked the cancel, so leaving the loop left the model billing.

  8. 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.

  9. 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 the DropSink; anyone hand-constructing the engine must pass it explicitly.


Options On remote-http

RemoteHttpOptions carries the request target and the refusal callback.

OptionTypePurpose
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".
httpHttpPortAll I/O goes through this port โ€” no fetch in the engine.
tokenstringOptional bearer token; loopback is unauthenticated, off-device must not be.
onIgnored(reason, detail) => voidCalled for every frame the decoder refused. Wired to dropSink.note by the composition root.
idstringOptional 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.

ts
// 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:

MapRecordsRead 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 arrivesdecide(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.

Diagram
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 404s

Without 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 200Verdict
{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 / 0unhealthy

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.

ts
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.

StepCheckOutcome
1Is id known?Fails: unknown_engine
2Does protocolVersion match?Fails: protocol_mismatch
3aProbe returns ready: false, retryable: falseFails: version_mismatch. Engine disposed.
3bProbe returns ready: false, retryable: trueSucceeds 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.

Diagram

Yes

No

tauri

web

๐Ÿš€ First launch

๐Ÿ’พ engineId persisted?

โœ… Use persisted engineId

๐Ÿญ defaultEngineIdFor(platform.kind)

๐Ÿ“ฑ platform.kind

praisonai-ts (in-process)

remote-http

๐Ÿ–ผ๏ธ mount uses this as engineId

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.

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.

EngineReceives persistenceWho owns the write
In-process (praisonai-ts)YesThe mobile session on this device
Remote HTTP (remote-http)NoThe 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.

EngineWrites the turn toReports end.userIndex fromReads history for the next turn from
praisonai-ts (in-process)The mobile app’s session store, via the RunPersistence handed in at constructionThe store it just wrote toThe mobile app’s session store, via ConversationHistory (historyFor(session)), bounded by HISTORY_CHAR_BUDGET
remote-httpThe desktop/remote server it POSTs the run toThe 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:

ts
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.

FieldMeaning
userIndexPosition of the user message on disk
assistantIndexPosition of the answer on disk
versionsNumber of stored versions
activeWhich 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
Yesfinish.text โ€” the streamed deltas are discarded for the write
NoConcatenation of every delta.text in order
Diagram

Yes

No

๐Ÿงพ Turn ends

๐Ÿ” Saw a finish event?

โœ… Persist finish.text

โœ… Persist concat of deltas

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.
ts
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.

MemberTypePurpose
idstringStable id, written into persisted chats.
protocolVersionnumberChecked once at boot; a mismatch fails boot with a name.
capabilitiesEngineCapabilitiesWhat 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.

EngineWhereTalks to
praisonai-tsengines/src/praisonai-tsThe agent loop, in-process on the device.
remote-httpengines/src/remote-httpA PraisonAI engine over HTTP + SSE.
scripted faketesting/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:

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, not praisonai. The default entry re-exports the CLI, the MCP server and the knowledge store, whose Node builtins are import-time fatal in a webview. /mobile is the package’s webview-safe allowlist entry.
  • One file may name it. tools/boundaries.json allows the string praisonai only from engines/src/praisonai-ts โ€” the agent-framework seam. Making the import literal did not cost that rule; load-agent.ts is 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:

BudgetCoversCeilingMeasured at the chrome58 floor
SHELL_BUDGET_BYTESthe entry chunk + its static import graph โ€” fetched and parsed before the first frame, on every cold start400 kB87.7 kB (app.js + esbuild’s interop helper)
LAZY_BUDGET_BYTESeverything reachable only through import() โ€” the engine and its remaining provider stack1000 kB912.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.

ts
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.

ts
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 modebreaks the run by
no_startSkipping its opening event.
start_onlyOpening and then simply stopping, with no terminal event at all.
tool_failure_as_okReporting a failed tool as ok.
empty_is_fineReporting an empty stream as an empty answer instead of an error.
decide_always_trueReporting an unknown approval as accepted.
abort_ignoredNever 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

Diagram

Tool rows, approvals,
reasoning in the UI

On-device, no server

Conversation memory that
survives an app relaunch on-device

Deterministic tests

What do you need?

remote-http

praisonai-ts

scripted fake


Common Patterns

An engine whose prerequisites are absent is omitted, not offered and then failed.

ts
// 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.

ts
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