Capabilities & Gaps

The capability matrix, the honest gaps, and why a false flag does not mean a missing feature.

A capability flag describes what an engine can report, not what it can do.

ts
import { PRAISONAI_TS_CAPABILITIES } from "praisonai-mobile/engines/praisonai-ts";

if (PRAISONAI_TS_CAPABILITIES.tools) renderToolRows();
Diagram

true

false

🧠 Engine

📋 capabilities

✅ UI renders rows

⚠️ UI renders nothing

Diagram

Closed Gaps

💾 Gap 4: turn write

✅ CLOSED

⌨️ Gap 5: keyboard seed

⚠️ Gap 6: decoder refusals

🗑 delete a conversation

🏷 accessible row names

💬 conversation memory

Quick Start

  1. Read a capability before rendering
    ts
    const caps = engine.capabilities;

    Capabilities are a property, so the UI decides what to render before the first token.

  2. Print the unsupported scenarios
    bash
    npm run test

    The conformance suite prints every scenario an engine cannot produce.


The Capability Matrix

What each shipped engine can report.

Capabilitypraisonai-tsremote-http
streaming✅✅
reasoning❌✅
tools❌✅
approvals❌✅
cancellation✅✅
attachments❌✅

Closed Gaps

The rows kept from the old gap report so a reader arriving from an old bug can find where they went.

CapabilityStatusNotes
In-process engine writes through the app’s session✅ SupportedCloses Gap 4. Only praisonai-ts writes; remote-http does not. (PR #4552)
Keyboard snapshot on first paint✅ SupportedCloses Gap 5. Seeded from visualViewport, guarded against pinch-zoom. (PR #4552)
Decoder refusals reach the transcript✅ SupportedCloses Gap 6. A refused frame becomes a dropped row instead of vanishing. (PR #4560)
Native shell events (lifecycle, safe-area, Android back-gesture) and secure link opening✅ SupportedThree of four Tauri shell events now emit natively; tauri-plugin-back-gesture (in-repo) owns the Android callback and Rust arbitrates via Gate with a 400 ms watchdog; tauri-plugin-opener makes openExternal actually reach the OS. (PR #4742)
A conversation can be deleted from the device✅ SupportedCloses the session.remove → repository.remove → storage.remove unwired chain and the un-decoded delete-chat intent. Two-tap arm/confirm; a refusal is announced; deleting the open chat clears it. See History & Reopen. (PR #4794)
Transcript rows carry accessible names✅ Supportedpaint() now calls accessibleName on insert and update; tool/approval/error rows announce status/decision/title in words. See i18n & A11y. (PR #4794)
Tool status is announced in words, not colour alone✅ SupportedA .tool-status span rendered from strings.toolStatus(row.status); the CSS-only unresolved blind spot is closed. (PR #4794)
Chat rows show when each conversation was last used✅ SupportedupdatedLabel reaches the visible row and the accessible name from one string (strings.chatUpdated). (PR #4794)
TimePort.epochMs is the wall clock; nowMs paces the announcer✅ Supportedmain.ts reads the port for records and uses the monotonic clock for elapsed comparisons — see Time & Pacing. (PR #4794)
The in-process engine restores conversation memory to the model✅ SupportedBefore this fix, the app stored and rendered a conversation but the model saw only the latest prompt. Now Agent.setHistory(truncateHistory(historyFor(session).messages())) runs on every turn, bounded to HISTORY_CHAR_BUDGET (24,000 chars, ~6,000 tokens). Survives an app relaunch because history is read from the session store. remote-http deliberately not covered — server owns it by chat_id. See History & Reopen → The model actually gets the conversation. (PR #4816)

Open Gaps

Known-open, stated rather than implied so a follow-up author can pick them up.

CapabilityStatusNotes
Truncation notice in the UI⚠️ DeferredWhen history exceeds HISTORY_CHAR_BUDGET, oldest turns are dropped from the model’s view. The transcript on screen is unchanged, but the model cannot refer to the oldest turns and the user is not told. Protocol v2 has no event for “context was trimmed” and adding one is a protocol bump. truncateHistory already returns dropped, so the value a future notice would carry exists; nothing consumes it yet.
Local mirroring of remote-http conversations⚠️ DeferredA chat answered by the default remote-http engine leaves the local session empty and has no local history to restore. Same scope note as the existing remote-http boundary — the server owns that conversation by chat_id.

Reported, not fixed

The capabilities PR #4794 audited but deliberately left unwired, named here so a follow-up author can pick them up.

CapabilityStatusNotes
TranscriptView.actions and TranscriptView.usage are computed and read by nothing⏳ ReportedbuildTranscript returns { fork, delete, retry, copy, stop } and a formatted usage line; publish uses only built.rows. So strings.actionFork, actionDelete (on a turn — distinct from the chat-list one wired here), actionRetry, actionCopy, usageChars, usageElapsed, usageTimeToFirstToken render nowhere, and the retry/copy intents intents.ts decodes are unreachable.
Error-row recovery has no affordance⏳ ReportedrecoveryFor(kind) decides "retry" vs "settings" and reaches the DOM only as data-recovery, which no CSS or handler reads. The "settings" branch belongs to the API-key work in flight.
shell.share(), shell.openExternal(), shell.haptic() have zero callers⏳ ReportedBoth adapters implement all three and the contract suite exercises them. There is no share affordance, and dom.ts renders text only, so there is no link for openExternal to open. Genuinely unbuilt features rather than broken ones.
formatRelativeLocalised, formatCountLocalised, formatDate, formatNumber have no callers⏳ DeliberateOnly formatElapsedLocalised is called (from announce.ts). format.ts says the view layer may localise later; only en ships today, so noting rather than wiring keeps a second formatter from drifting from the one the view model publishes.

Why tools: false For praisonai-ts

praisonai-ts executes tools normally; it just never announces them. Upstream Agent.streamEvents() emits a three-variant union — text, finish, error — and none of those carry a tool call, so the engine cannot report one.

ts
type AgentEvent =
  | { type: "text";   delta: string }
  | { type: "finish"; text: string }
  | { type: "error";  error: Error };

The flag describes what the engine can report. A UI that renders tool rows off a true flag would render nothing and look broken — and a tool call that silently failed would be indistinguishable from a normal answer, which is exactly what tool_result.ok was introduced to prevent.


How Each Gap Closed

Gap 4 was “mechanism landed, not wired”: createSession was called and RunPersistence.record was called, and nothing connected them — the two signatures did not even line up (record(prompt, answer) against record(request, answer)).

ts
// The named adapter where the two vocabularies meet.
export function persistenceFor(session: Session) {
  return { record: (request, answer) => session.record(request.prompt, answer) };
}

Gap 5 was “property added, snapshot not seeded”: keyboardHeightPx was declared = 0 and only updated by an event, so a component mounting while the keyboard was already up laid out at 0 for one frame and then jumped.

ts
let keyboardHeightPx = readKeyboardHeight(view); // seeded at construction

Both are verified by a positive control: reverting either makes a named test fail.

Gap 6 was also “mechanism landed, not wired”: the Dropped type, the view-model row and the seven user-facing strings all existed with no producer. remote-http was the only production caller of decodeEvent and discarded every rejection, so a malformed frame made its tool vanish and the turn rendered as a clean answer.

ts
// The channel between the engine and the controller, neither of which can
// call the other. Refusals are drained onto the transcript per event and
// again in finally, so one beside the last frame is still shown.
export function createDropSink(): DropSink { /* note + drain */ }

A carry field fixed the cross-turn contamination: apply(start) no longer carries the previous turn’s entire dropped list, so one refusal on turn 1 no longer paints every later turn as damaged. Two composition tests pin the two wiring hops — createApp and the real enginesFor — because removing the sink from createRunController or dropping the registry’s forward each left the suite green in isolation. See Dropped Events.


The Unsupported Scenario

From src/praisonai-mobile/docs/gaps.md, the one conformance scenario praisonai-ts still declares unsupported. Tool scenarios and the single-approval scenario are produced and passing since upstream gained tool_call/tool_result and the dead approval skip guard was retired in PraisonAI PR #4641.

ScenarioReason
two_approvalsApprovalManager gates tool execution upstream but its prompt cannot reach the event channel, so two approval_request events cannot be emitted.

Each is printed on every run, so a contract that quietly shrinks is visible rather than silently green.

approval runs because that case only asserts that deciding an unknown id returns false, which praisonai-ts‘s decide() always does — no approval channel needed. capabilities.approvals stays false because the engine still cannot emit an approval request, which is what two_approvals needs.


Roadmap

The in-process engine now ships and boots on a device: the mobile path imports praisonai/mobile — the package’s webview-safe entry (praisonai-ts #4720) — as a lazy chunk, and defaultEngineIdFor("tauri") returns praisonai-ts. The CLI-oriented agent/simple.ts still carries Node-only imports (crypto, and events in ai/tool-approval.ts), but those are not for the webview — the /mobile entry avoids them, so they no longer block the mobile app.

The remaining device gap is the API-key setting: the registry declares no secret setting yet, so a first turn reaches the model layer and fails recoverably for want of a key. A key setting is the tracked follow-up. See Engines → The known API-key gap.

Remaining native-shell gaps (tracked in src/praisonai-mobile/docs/gaps.md):

  • keyboard-height is still never emitted natively; the TypeScript reads visualViewport and would take a native event as an override.
  • iOS edge-swipe back is not installed.
  • Haptics and share have no plugin; their bridge invokes reject and the bridge degrades.

See Native Shell for the events that now emit.


Best Practices

Render from the capability, not from hope

Check capabilities.tools before drawing tool rows; the flag is the contract the conformance suite enforces in both directions.

Declare gaps, never fake them

An honest unsupported entry keeps the suite meaningful; a faked scenario hides a defect it exists to catch.

Switch engines to gain capabilities

remote-http speaks the full vocabulary because the desktop server already emits it.

Close a gap only when it is wired end-to-end

A mechanism existing is not the same as a mechanism working. Gaps 4 and 5 each landed a type or a function before the wiring, and read as closed from either end until re-audited.

Keep retired gap rows visible

The Gap-4 and Gap-5 rows stay in the matrix, linked to PR #4552, so a reader coming from an old bug report can find where they went rather than assuming they were dropped.

State scope so it is not read later as an oversight

The remote-http exclusion is deliberate. Documenting it up front stops a future reader from filing the empty local session as a regression.


Related