Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Fable 5.1 59f69e0ee8 fix(agent): run context compaction on the streaming path
Agent auto-enables a ContextManager whenever tools are present
(auto_compact, threshold 0.8), and the non-streaming chat() path honours
it by calling _apply_context_management between building the messages
and issuing the request. start(stream=True) / iter_stream built the same
messages and sent them unmodified, so a long session streamed its entire
history to the model on every turn while the configured manager sat
idle. The desktop's default agent has tools, so every streamed turn hit
this once the conversation grew.

Both streaming branches now call the same _apply_context_management the
non-streaming path uses: the OpenAI branch on the built messages list
(which the post-tool follow-up request also reuses), and the custom-LLM
branch on the chat_history that is actually handed to
get_response_stream. No new compaction machinery; context=False remains
zero-overhead because the helper returns immediately when no manager is
configured.

The test stubs the transport, seeds 400 long messages (~160k estimated
tokens against a 128k window) and asserts the message list that reaches
the transport is bounded on both branches, plus a context=False control
that pins the full history going out untouched. Removing either call
makes the corresponding test fail.

Closes #4714

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-02 23:17:19 +01:00
94a225283e fix(parity): make the Python<->TS parity tracker a gate, and check signatures (#4706)
* fix(parity): make the Python<->TS parity tracker a gate, and check signatures

The exported-name tracker only ever regenerated and auto-committed; its
--check mode could not pass off the GitHub runner (absolute paths in the
JSON), 26 of its last 30 commits changed only the date, `export *` barrels
were invisible, and a shim module written to satisfy name matching counted
as "done" for 56 features. Rust reported 162% parity beside 129 gaps.

Names layer (generator.py, typescript_extractor.py):
- repo-relative paths; lastUpdated preserved when content is unchanged
- export * resolution, camelCase fallback, TS alias table, one presence
  rule shared by summary and gap matrix
- rows provided only by src/parity/index.ts are STUB, not DONE
- Rust status/percentage derived from the gap matrix
- personal fallback path replaced by _paths.find_repo_root (raises)
- Rust file scan sorted so output is stable across OSes

Signatures layer (new _dev/parity/signatures/):
- surface.yaml (10 curated surfaces), rules.yaml (case, aliases,
  flattening, default equivalences), waivers.yaml (168 baseline entries)
- py_extract.py (AST only), ts_extract.mjs (TypeScript compiler API,
  exit 2 when the compiler is missing, never an empty pass)
- compare.py: --write/--check/--diff/--baseline; fails on un-waived
  drift, expired or stale waivers, or a run that compared nothing
- generates src/praisonai-ts/SIGNATURE_PARITY.md + signature-parity.json

CI:
- parity-gate.yml: two named PR jobs (names, signatures) that fail on drift
- update-parity-tracker.yml: fixed Rust path filter, added generator and
  CLI feature paths; still regenerates on push to main

Tests: 19 -> 110, each behavioural test paired with a control.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* feat(parity): flatten options-object members into method signatures; add --prune

A TS method that takes an options interface (chat(prompt, options?: AgentChatOptions))
now reports the interface members as parameters, with defaults read from
options.x ?? v in the body, so Python keyword arguments map one to one.
--prune removes waivers whose gap has been closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(parity): read LLM/tool ctor defaults; treat Python None as equivalent to an empty TS literal

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(parity): waive tool().description default with its reason; regenerate report

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 23:07:39 +01:00
praisonai-triage-agent[bot]andMervinPraison e3e6b8621e fix: give desktop projects real context via per-project instructions (fixes #4697) (#4711)
Projects were a sidebar label the /chat route never read. Add a per-project
instructions store (projects.json), wire the previously-dead GET /projects to
report it plus a POST /projects to edit it, and have /chat prepend a chat's
project instructions to the turn (not the persisted prompt). Sidebar project
labels are now clickable to edit those instructions.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 23:05:22 +01:00
MervinPraison 45b440f243 docs: auto-update feature parity trackers [skip ci] 2026-09-02 22:03:36 +00:00
MervinPraison 0eca1000b9 docs: auto-update feature parity trackers [skip ci] 2026-09-02 21:57:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 24a4b71c94 fix: interpret naive scheduler 'at' timestamps as local time (fixes #4691) (#4705)
is_due() stamped naive one-shot 'at' timestamps as UTC, so in a non-UTC
zone a job typed from local wall-clock fired an hour (or more) off and
test_update_cadence_resets_last_run failed. Naive 'at' now resolves to the
schedule tz / PRAISONAI_SCHEDULE_TIMEZONE when set, else the local zone.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 22:55:31 +01:00
Mervin PraisonandClaude Fable 5.1 b43db27879 fix(scheduler): declare croniter so daily and weekday schedules actually fire (#4690)
Every cron-kind schedule was accepted, persisted, and never ran. croniter was
declared in none of the nine pyproject.toml files, and is_due() answers False
for a cron job when it cannot import the engine -- a log line, no error at
creation, a job that never fires.

parse_schedule() converts every natural-language recurring form to cron, so
"every day at 9am" and "weekdays at 9am" -- the schedules people actually
write -- were in the broken half. Interval ("hourly") and one-shot ("in 20
minutes") never needed the engine and were fine, which is how this went
unnoticed: the simplest test cases passed.

Two changes, one per layer of the failure:

  * croniter>=6.0 joins the core dependencies (pure Python; pulls only
    python-dateutil), so a default install can evaluate what it accepts.
  * parse_schedule() refuses a cron schedule when the engine is absent,
    raising ValueError with the install hint. Both call sites route through
    one helper. The failure moves from "silently at fire time" to "loudly at
    creation", which is the only moment the user can act on it.

Tests assert the effect -- a daily job is due eight days after creation --
and pin the packaging by reading pyproject.toml, since packaging, not code,
was the defect. All four mutations killed: undeclaring the dependency, a guard
that never raises, and unguarding either call site on its own.

Not touched: test_update_cadence_resets_last_run fails on main independently
of this change, on any non-UTC machine -- naive `at` timestamps are stamped
UTC by is_due() while the test (and any user) means local time. Filed
separately.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-02 22:55:14 +01:00
praisonai-triage-agent[bot]andMervin Praison d944fcc8fe fix: show installed skills in interactive / menu (#4703)
* fix: show installed skills in interactive / menu (fixes #4692)

Pass include_skills=True when building the command registry in both
interactive surfaces (REPL and TUI), so discovered skills appear in the
`/` completion menu and dispatch like custom commands.

Co-authored-by: MervinPraison <[email protected]>

* fix: make interactive skills runnable and discoverable in / menu

Skills were registered in the interactive command registry but had no
handler, so both dispatchers fell through to "Unknown command"; and the
completion menus were fed only built-ins, so skills never appeared after /.

- Give SkillCommandSource a handler that renders the SKILL.md body + args
  into a prompt (mirrors CustomCommandSource), so /name actually runs.
- Feed discovered registry commands into the REPL completer (warmed at
  startup) and the async TUI completer, so installed skills show after /.
- Add regression test asserting the skill handler yields a runnable prompt.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 22:54:43 +01:00
ac984816c6 test: guard lazy-command module paths in praisonai-code CLI (#4704)
* test: guard lazy-command module paths in praisonai-code CLI (fixes #4693)

Add a regression test that imports every non-resident module named in
``_LAZY_COMMANDS`` (plus ``_SPECIAL_COMMANDS``) so a dangling ``.commands.*``
path fails in CI rather than at first use. Wrapper/bot/train/browser/mcp/
deploy-resident commands keep placeholder paths re-routed at invocation time
and are excluded, matching runtime dispatch.

Co-authored-by: MervinPraison <[email protected]>

* test: cover resident lazy-command re-routes (incl. schedule) in CLI guard

Adds a conditional resident-command test mirroring get_command()'s dispatch:
when the owning package (praisonai/praisonai_bot/train/browser/mcp/deploy) is
installed, it imports the absolute .cli.commands.<name> target and asserts the
attribute exists; it skips gracefully in standalone praisonai-code. This closes
the untested schedule -> praisonai.cli.commands.schedule re-route gap (#4693).

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:54:23 +01:00
fccb0f24aa fix(mobile): a corrected engine address takes effect, and a refused one is said (#4694)
* fix(mobile): a corrected engine address takes effect, and a refused one is said

The recovery path a phone depends on is: reach no 127.0.0.1:8765, open
Settings, type the engine's real address. #4685 (refs #4673) made the fields
editable, but the path still ended short of a working engine in two places:

1. The write persisted and the engine ignored it. `enginesFor` read `baseUrl`
   once, at construction, and both the remote-http engine and its health probe
   closed over that string. The app builds its engine exactly once at boot, so
   the corrected address was stored, displayed, and never used until the app
   was force-quit. `RemoteHttpOptions.baseUrl` now also accepts a resolver and
   the registry passes one, so the NEXT message and the next probe go where the
   user just said. A plain string still works; rebuilding the engine on change
   was rejected because `createRunController` has no seam to swap it and would
   drop the turn in flight.

2. A refused value vanished. `settings.set` returning false, or `validateInput`
   returning null, reset the field in silence -- indistinguishable from a
   mis-tap or a save that worked, on the one screen whose job is repair. The
   refusal is now written beside the field (a `role="alert"` node that exists
   from first paint, so it is announced reliably) and into the assertive live
   region, naming the setting. Only that key's note is touched: an accepted
   write clears its own message and leaves another setting's refusal standing.

Committing a field also moved out of a per-field `change` listener into
`intents.ts` as `set-setting` and a root-delegated `change` handler, like every
tap. Settings was the one affordance that bypassed `intentFrom`, so the
decisions (which key, what was typed, whether this element is a field at all)
could only be tested by synthesising DOM events. `Actionable.value` is absent
on non-fields on purpose: a missing value means "not a field", never "cleared".

The fake DOM gains a bubbling `change`, because a dispatch on the field alone
passes against a per-field listener and reports nothing against a delegated
one. Tests pin each link: the next /chat and /health go to the corrected
address without a relaunch, a refusal is shown and spoken, an accepted write
clears only its own note, and the decoder refuses a keyless or valueless
set-setting.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(mobile): pin a run's control requests to the engine that owns its id

A mid-turn address change redirected /cancel and /approve to the newly
typed address, but the runId/approvalId was minted by the engine the run
started at -- the new engine refuses a foreign id, and controller.ts only
aborts the local reader once cancel returns true, so the original turn kept
generating while the user believed they had stopped it. Pin each run's
origin base and route its control requests there, falling back to the live
resolver only for an id this instance never saw.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:47:30 +01:00
MervinPraison 709895066f docs: auto-update documentation parity trackers [skip ci] 2026-09-02 21:44:15 +00:00
51e13c33c0 fix(parity): the drift gate was blind, and the tracker could not fail (#4689)
* fix(parity): the drift gate was blind, and the tracker could not fail

Three agents audited how Python<->TypeScript parity is managed. The findings
below are each reproduced; the fixes are the ones that need no design
decision. What the tracker MEASURES is a separate problem, reported
separately.

THE ONE REAL DRIFT GATE NEVER FIRED ON DRIFT. `mobile.yml` runs
`check-upstream-parity`, which typechecks the real praisonai-ts `Agent`
against the structural contract in `agent-api.ts` -- the only mechanism in the
repo that fails a build on upstream signature change. Its path filter watched
`src/praisonai-mobile/**` only, so it fired when the CONSUMER changed and was
silent when the thing it watches did. Verified against 71197fa80, which edited
`src/praisonai-ts/src/agent/simple.ts`: no parity check ran on that commit. It
now watches `src/praisonai-ts/src/**` too.

`--check` FAILED ON EVERY MACHINE THAT IS NOT THE RUNNER. The generator
embedded `str(self.repo_root / ...)` in four places, so the committed JSON
carried `/home/runner/work/PraisonAI/...` and the field flipped between that
and `/Users/praison/...` across commits. Made repo-relative; `--check` now
exits 0 locally for the first time.

THE TRACKER COULD NOT REPORT FAILURE. Both extractors swallow their errors and
return empty, and every number downstream is a set difference -- so an
unparseable `__init__.py` produced `{'pythonCoreFeatures': 0, 'gapCount': 0}`
and exit 0. CI would have committed that as "no gaps". Zero exports is now
refused with a non-zero exit, and the trackers are left untouched. Three
existing fixtures built exactly that empty tree; they now declare one export,
because an empty tree was never a state worth generating from.

RUST REPORTED 162.3% PARITY, "PARITY_ACHIEVED", WITH 129 GAPS. `parityPercentage`
divided total Rust exports by Python's -- volume, not parity -- and the status
fired whenever the crate exported more names, however much was missing. The
`PARITY.md` from the same run said 69.8%. Now implemented/expected (68.6%) and
`PARITY_ACHIEVED` requires zero gaps.

RUST NEVER TRIGGERED EITHER WORKFLOW. Both filtered on
`src/praisonai-rust/src/**`, which does not exist -- the crate is at
`src/praisonai-rust/praisonai/src/`. The Rust numbers only refreshed
incidentally, on an unrelated push. Also added the generator's own inputs
(`_dev/parity/**`, `cli/features/**`), which it reads and neither workflow
watched.

`--check` IS NOW A GATE. It has existed since the script was written, exits 1
on staleness, and was invoked by nothing. A `pull_request` job runs it; the
committing job is restricted to `push`, since regenerating onto a
contributor's branch is not this workflow's business and would fail on a fork.

This checks STALENESS, not parity. What "DONE" means is unchanged and remains
what the tracker's own disclaimer says it is.

22 tests pass in test_parity_tracker.py, including three new ones covering the
refusal and its pair.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(parity): regenerate stale Rust tracker so the new --check gate passes

The committed Rust FEATURE_PARITY_TRACKER.json lagged the crate's current
module layout (ToolDefinition and AutoRagConfig had moved), so the very
staleness gate this PR introduces failed on HEAD. Regenerated from source;
status/percentage stay consistent (NEAR_PARITY, 68.6%).

Co-authored-by: Mervin Praison <[email protected]>

* fix(parity): the new --check gate needs pyyaml, like the push job

Its first run failed in 8s: `ModuleNotFoundError: No module named 'yaml'`.
The generator imports pyyaml; the committing job installs it and the new
checking job did not. Same step, copied.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:40:59 +01:00
MervinPraison 667706adcf docs: auto-update feature parity trackers [skip ci] 2026-09-02 20:57:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e3f5c7f94 fix(mobile): make Settings editable so a phone can reach its engine (#4685)
* fix(mobile): make Settings editable so a phone can reach its engine (fixes #4673)

The device default is remote-http at http://127.0.0.1:8765, which a phone
cannot reach; it stays the default because the in-process engine's module is
absent from the shipping webview, so defaulting to it would swap a recoverable
warning for an unrecoverable "unavailable in this build". The one recovery the
remote default needs -- change baseUrl in Settings -- did not exist: every row
rendered as a read-only <span> and facade.set / validateInput had no caller.

buildSettingsScreen now renders value rows as <input>/<select> wired through the
existing pure validateInput to facade.set. A refused write resets the field to
the stored value. Secret rows stay presence-only. No new module, param, export,
or default.

Co-authored-by: Mervin Praison <[email protected]>

* fix(mobile): a failed settings write stays local, not a fatal crash

A settings edit persists through StoragePort, which rejects on a real
device (SecurityError with site data blocked, QuotaExceededError). The
fire-and-forget `void commit(...)` let that rejection float to the global
crash handler, replacing the whole app with the fatal screen -- and the
store had already committed the value to memory before persisting, so the
field showed an address the next launch would never read.

- settingControl.commit now catches a rejected `set` and resets the field
- store.set persists BEFORE mutating memory, rolling back on failure so a
  refused write leaves no phantom value and marks no key chosen
- regression tests: a storage failure while editing stays local (main),
  and a write whose persist fails rolls back and wakes nobody (store)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:56:56 +01:00
praisonai-triage-agent[bot]andMervin Praison fe08ef03f5 fix: route bare model + base_url as OpenAI-compatible (#4684)
* fix: route bare model + base_url as OpenAI-compatible (fixes #4535)

A bare catalog model id (no provider/ prefix) with a custom base_url
(e.g. llm="deepseek-v4-flash", base_url=".../v1") caused LiteLLM to raise
"LLM Provider NOT provided". Prefix openai/ at request-build time so it
routes through the OpenAI-compatible client, leaving anthropic/gemini/
ollama models untouched.

Co-authored-by: Mervin Praison <[email protected]>

* fix: route bare model through openai/ when subscription base_url is set

Subscription auth providers (qwen-cli, codex) inject an OpenAI-compatible
base_url at request time. _resolve_openai_compatible_model() previously only
checked self.base_url, so a bare model under subscription auth reached LiteLLM
unprefixed and raised "LLM Provider NOT provided". Now consider the effective
base_url (instance or subscription-supplied). Addresses Greptile review.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:56:43 +01:00
Mervin Praison b3d9cc008e fix(mobile): the unresolvable-import check flagged installed packages too (#4686)
A defect I shipped in #4678. The check asked "did this bare import stay
external?" -- which is true of EVERY bare import, because the plugin in this
same file externalises them all on purpose so Node builtins surface in the
metafile instead of being silently shimmed.

So it reported installed, perfectly resolvable packages as unresolvable. It
passed only because the shipped bundle happens to have no bare imports at all,
and it would have blocked the first real dependency the app ever added.

Caught while testing `[email protected]`, published today with the `./mobile`
entry the in-process engine needs: the gate reported `praisonai/mobile`
unresolvable while `createRequire` resolved it to
node_modules/praisonai/dist/mobile.js.

It now asks the question it claims to ask -- can the specifier be RESOLVED
from the entry -- so an installed package passes and a missing one still
fails. Both directions are tested; the first version had only the failing one,
which is why the false positive went unnoticed.

The probe for the passing direction lives inside the package rather than a
temp directory, because resolution is relative to the entry: a temp-dir entry
has no node_modules above it, so everything looks missing and the test passes
for the wrong reason. That was the first draft, and it failed for exactly that
reason before being corrected.

Three mutations; two die. The third -- dropping the Node-builtin exemption --
is EQUIVALENT and now says so in the source: createRequire resolves builtins,
verified, so the line is intent rather than behaviour.

1327 tests on Node 22.23 and 24.12.
2026-09-02 21:55:41 +01:00
praisonai-triage-agent[bot]andMervin Praison a07b512646 fix(mobile): route the coalescer flush-tick publish through the publish gate (#4687)
* fix(mobile): route the coalescer flush-tick publish through the gate (#4640, #4639)

The publish gate (core/src/pacing/publish-gate.ts) carried two mobile-tuned
constants (MAX_HELD_CHARS=96, UNPAINTED_REOPEN_MS=200) but was unreachable in
the shipping pipeline: the coalescer's flush tick drains every maxDelayMs, so
`coalescer.push()` in the run loop almost always returned [] and the per-event
gate was never consulted, while the tick's own publish was ungated. A module
with tuned constants that never runs reads as load-bearing (audit #4639).

Wire the tick publish through the gate. `gate()` is TRUE whenever the gate is
open, and it is open exactly when nothing has painted recently, so the tick
after a quiet period still paints immediately; the gate only skips a tick that
fires just after a paint, which is the backpressure path this pipeline lacked.
MAX_HELD_CHARS still forces the tail out and the final finally-publish stays
ungated, so no token is lost.

Pinned by a controller test: 40 ticks with no frame release produce a handful
of paints, not 40 (reverting to the ungated tick gives 45/40 and fails).
gaps.md updated in the same commit per its own close-it-here rule.

Co-authored-by: Mervin Praison <[email protected]>

* fix(mobile): release backpressure so a gate-rejected tick cannot strand text (#4687)

The flush tick drains the coalescer before the publish gate is consulted, so a
frame the gate rejects is already gone from the coalescer -- its text is on the
transcript but unpainted. If the stream then pauses, no later tick sees pending
text and gate reopening does not itself publish, leaving already-received text
invisible until the next delta or the final publish (Greptile P1).

Track how far the screen has actually painted (paintedChars) and, on a tick that
finds nothing new to drain, flush that stranded progress once the gate reopens.
Adds a regression test (positive control: fails 'hello ' vs 'hello world'
without the fix) and documents the catch-up in gaps.md.

typecheck ✓, boundaries ✓ (143 files), 1328 tests 0 failed (+1).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:55:38 +01:00
praisonai-triage-agent[bot]andMervin Praison de60cc9455 docs(examples): meeting-notes agent from existing primitives (#4683)
* docs(examples): add meeting-notes agent example composed from existing primitives (#4622)

Demonstrates the AI meeting-notes pipeline (transcribe -> summarize ->
index for Q&A) using existing AudioAgent, Agent, and knowledge= RAG.
No new SDK surface added; keeps core lightweight per AGENTS.md.

Co-authored-by: Mervin Praison <[email protected]>

* fix(examples): print final Q&A result in meeting-notes agent

Greptile P1: qa.start() return was discarded so non-interactive/redirected
runs omitted the advertised Q&A output. Now printed explicitly.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:45:39 +01:00
bef31f9258 fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor (#4678)
* fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor

Three agents audited the package against one question -- what stops this being
an installable iOS/Android app. Every finding below was reproduced before it
was fixed, and every fix is re-run against the mutation that reverts it.

THE APP COULD NOT REACH ANY ENGINE. `connect-src 'self' ipc:
http://ipc.localhost` permitted no engine address at all, while `baseUrl` is a
user setting that accepts any host. Under Tauri the HttpPort is still browser
`fetch`, so a webview enforces connect-src on it: every request was blocked
before a packet left, including the shipped default. Proved by serving dist/
twice, once with the shipped CSP and once without -- "Refused to connect
because it violates the document's Content Security Policy". The desktop app
already carried the loopback entries; mobile also needs https:, because on a
phone the engine is across a network. script-src stays closed, and a test pins
that it does.

THE COMPOSER SAT UNDER THE KEYBOARD. `createTauriShell` had
`keyboardHeightPx = 0` whose only writer was the native `keyboard-height`
event -- and nothing emits it: `src-tauri/src/lib.rs`'s `on_window_event` is
an empty closure, left unwired until the mobile targets are initialised. Since
`app.css` pins `#root` to `position: fixed; inset: 0`, the layout viewport
does not shrink either, so the composer stayed under the keyboard from the
first tap. The WEB shell had this right all along and nothing compared the
two. It now seeds and tracks from `visualViewport`, reusing the web shell's
`readKeyboardHeight`, and the native event takes over the moment it exists --
one writer at a time, so they cannot race.

Found while testing it: the fake `visualViewport` had `addEventListener` and
no `removeEventListener`, so no adapter that unsubscribes could be exercised
there. The fake now models both.

THE BUNDLE COULD NOT PARSE ON THE FLOOR IT DECLARES. `TARGETS` said
`chrome108` while tauri.conf.json declares `minSdkVersion: 26` -- Android 8.0,
WebView ~Chrome 58 -- and the output carried optional chaining, nullish
coalescing and logical assignment. index.html loads it as a module, so that is
a parse error: the body never evaluates, and `installCrashHandler` is imported
by the same module so it never installs either. A blank white screen with no
error surface. Nothing linked the two numbers; they are derived now, and
tested against each other. Cost: 5.2kB of a 400kB budget.

EVERY SCREEN RENDERED AT ONCE. `.screen { display: flex }` is an author
declaration and beats the UA `[hidden] { display: none }`, so `el.hidden =
true` in mount.ts did nothing. Measured in headless Chrome: after tapping
Settings the chat screen was still on screen at half height with the settings
list stacked underneath, halving again on every navigation. ui/src/screens.ts
was correct throughout; only the stylesheet broke it.

THE COMPOSER'S SAFE AREA WAS COUNTED TWICE, and its gutter lost. `geometryOf`
already returns `max(keyboard, insets.bottom)` -- insets.ts calls the sum "the
single most recognisable 'web page in a box' tell there is" -- and app.css
added the inset back: measured 76px where 42px is correct. Separately main.ts
wrote `padding-inline` inline, beating the stylesheet's gutter, so at 320px
the textarea sat flush at x = 0 while everything else kept 12px.

THE GATE PASSED BUNDLES THAT CANNOT LOAD. `problems` was built from three
checks -- static Node builtins, top-level process.env, size -- so a bare
import esbuild could not RESOLVE was reported shippable. Bundling
praisonai-ts's own webview entry through it reported 80.5kB, 0 problems and
`shippable: true` while silently leaving out openai, ai, four @ai-sdk
packages, chalk, ora, boxen, figlet and cli-table3. That loads on a laptop
with node_modules beside it and dies on a phone.

CI HAD NEVER COMPILED FOR A PHONE. Both `rust shell` legs are host targets, so
`#[cfg_attr(mobile, tauri::mobile_entry_point)]` -- the attribute generating
the Android JNI and iOS start_app symbols -- expanded zero times. A
`cross-check` job now runs `cargo check --lib` for aarch64-linux-android and
aarch64-apple-ios. It needs no NDK and no Xcode project; verified locally at
24s for the android leg. The iOS leg cannot be verified here (Command Line
Tools only, no iOS SDK) and is proved by this PR's own CI.

AND THE SECOND RELEASE WOULD HAVE BEEN REJECTED. tauri.conf.json is hardcoded
at 0.1.0 with nothing to change it -- the same root cause as #4527, but worse
than a wrong label: Tauri derives Android versionCode and iOS CFBundleVersion
from it, and both stores refuse a repeat. tools/set-release-version.mjs takes
the tag and a monotonic build number, following the single-source rule the
desktop package settled on.

Also: Cargo.lock is committed (this is an application; `tauri = "2"` resolved
independently on every machine), @tauri-apps/cli is a devDependency so a
mobile build can be attempted at all, and the generated mobile ACL schemas
land beside the desktop ones.

1326 tests on Node 22.23 and 24.12; cargo test green; cargo check green for
aarch64-linux-android.

Refs #4673, #4640

* fix(mobile): sync package-lock.json with the @tauri-apps/cli devDependency

CI failed in 11-21s across four jobs: adding the dependency to package.json
without regenerating the lockfile makes `npm ci` refuse, and every job starts
with it.

Reproduced locally by running `npm ci` against a copy of the two files, and
verified fixed the same way -- 7 packages added.

The lockfile was updated with --package-lock-only so node_modules was not
touched; this worktree symlinks another one's.

* fix(mobile): sync lock file for @tauri-apps/cli; build floor-parse check in-memory

All 7 CI jobs failed at `npm ci` because package.json added @tauri-apps/cli
but package-lock.json was never regenerated (EUSAGE, lock out of sync).
Regenerated the lock file so `npm ci` is in sync across check, rust shell,
cross-check, and upstream-parity legs.

Separately, tools/bundle-target.test.mjs's "shipped bundle" assertion read a
pre-built dist/app.js, but the `check` job runs `npm test` without a prior
`npm run build`, so it would fail once the lock file was fixed. Build the same
entry in-memory through the same bundle() at the same TARGETS instead -- same
output, no build-order dependency.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 20:15:40 +01:00
MervinPraison 34fc8d9439 docs: auto-update feature parity trackers [skip ci] 2026-09-02 18:23:01 +00:00
praisonai-triage-agent[bot] 2332f32c2e Merge pull request #4682 from MervinPraison/claude/issue-4681-20260902-1717
fix: remove duplicate dead completion-signal regexes in agent.py
2026-09-02 18:22:33 +00:00
praisonai-triage-agent[bot] 6801a60a96 Merge pull request #4680 from MervinPraison/fix/post-windows-gaps
fix(desktop): redact API key from settings replies; stop adopting training runs on a recycled pid
2026-09-02 17:52:39 +00:00
MervinPraison 5394ad4503 docs: auto-update documentation parity trackers [skip ci] 2026-09-02 17:44:14 +00:00
praisonai-triage-agent[bot] a15c7f8fb0 Merge pull request #4679 from MervinPraison/claude/multi-agent-praisonai-platform-dfbgzo
fix(agents): stop auto-compaction latching itself off for the rest of a session
2026-09-02 17:43:32 +00:00
praisonai-triage-agent[bot]andMervinPraison 3081ffb750 fix: remove duplicate dead completion-signal regexes in agent.py (fixes #4681)
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 17:18:54 +00:00
Mervin PraisonandClaude Opus 5 af6fc8402a test(agents): document the low-savings guard tests
CodeRabbit's docstring-coverage pre-merge check reported 50% against an 80%
threshold, scoped to the functions this diff touches.

Each test now carries a docstring saying why it exists rather than restating
its name — what breaks if the assertion stops holding. Three explanatory
comments that were already there moved into the docstrings, which is where
they belonged.

No behaviour change; the 11 guard tests still pass and the full
tests/unit/compaction/ selection is unchanged at 108 passed with the same 3
pre-existing failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-09-02 17:12:12 +00:00
MervinPraisonandClaude Opus 5 77e5dca9b4 fix(desktop): do not adopt a training run on a recycled pid
_reload() adopted any persisted run whose pid was merely alive. src-tauri/src/
adopt.rs already refuses that for the engine -- it compares `start_time` and
names the rejection `PidReused` -- but nothing guarded the training job, so the
same recycled pid the Rust shell rejects was adopted here.

_pid_alive's docstring put the cost at "one adopted-then-reaped run". Nothing
reaps an adopted run: it carries no _proc and no supervisor, so

  * it stays RUNNING forever and start() refuses to launch anything while a
    live run exists -- fine-tuning is wedged shut until someone finds and
    deletes the state file by hand, and
  * stop() takes the `proc is None and run.pid` branch and SIGTERMs the process
    group of whatever unrelated program now holds that pid.

Runs now record the child's start time beside its pid, captured at spawn while
the child is certainly still ours, and adoption requires both to match. A state
file with no fingerprint (written before this change) is refused: ownership
cannot be proven, and the penalty for guessing wrong is signalling a stranger.

Tested against real recycled pids rather than a mock liveness function. All six
mutations are killed, including _persist dropping the field -- which the first
draft of the tests missed, because they hand-wrote the state file and so would
have stayed green while adoption failed closed forever.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 18:02:36 +01:00
MervinPraisonandClaude Opus 5 f6ed3966c9 fix(desktop): stop settings replies returning the API key in cleartext
GET /settings has masked the key since the beginning. POST /settings replied
with save_settings()'s merged dict, and that dict starts from load_settings(),
which reads the real secret back out of the keychain -- so every settings write
answered with the credential in cleartext, including a write that only changed
the theme and never mentioned the key. The engine also sends
`Access-Control-Allow-Origin: *`, so any page open in the user's browser could
read it with a two-line fetch.

Masking one of two exits is not masking. redacted() is applied at both, and is
driven by SECRET_KEYS so a future secret is covered without new code. What is
stored is unchanged; only what leaves the process differs.

The tests assert the effect -- the secret must be absent from the response
bytes -- rather than the shape of the dict, because the dict was correct and
was simply the wrong dict. Reverting either call site turns them red (4/4
mutations killed).

They also set PRAISONAI_KEYCHAIN_SERVICE, not just PRAISONAI_DESKTOP_HOME:
the home var moves the data directory but not the system keyring, so a test
run against the default service overwrites the key the developer is actually
using and the keychain keeps nothing to restore. That is not hypothetical --
it happened while this test was being written.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 17:58:46 +01:00
Mervin PraisonandClaude Opus 5 dadb02f960 fix(agents): stop auto-compaction latching itself off for the rest of a session
The anti-thrashing guard in ContextCompactor could never be released. Once
`_low_savings_streak` reached `max_consecutive_low_savings` (default 2), both
`compact()` and `needs_compaction()` returned early — and the only line that
clears the streak sits *after* a compaction actually runs, which those early
returns skip. The compactor therefore stopped compacting permanently.

That is the opposite of the guard's purpose: it exists to avoid burning a
summarisation call on a conversation that will not shrink, but the effect was
that long-running agents — the ones auto-compaction exists for — silently
stopped compacting and let their context grow without bound.

Measured on a latched compactor, input it would otherwise shrink 2991 → 125
tokens (96%) came back untouched, indefinitely, while an identical fresh
compactor handled it. In a simulated 40-round session, compaction fired 4
times and then never again, with context climbing to 1172 tokens against a
300-token limit.

Fix: the guard can now go stale. `_should_skip_low_savings()` releases it once
the conversation has grown 25% beyond the size it gave up at, on the grounds
that materially more content is new evidence and the earlier verdict no longer
applies; the streak is then reset so the full allowance returns. Both the sync
and async paths, plus `needs_compaction()`, route through that one helper, and
the bookkeeping is likewise consolidated into `_record_savings_outcome()` so
the three sites cannot drift apart again.

Anti-thrashing itself is unchanged: an unchanged or marginally-grown
conversation is still skipped, and `was_skipped_due_to_low_savings` still
reports it.

Worth being precise about the limit: this converts "permanently disabled" into
"periodically re-tested". Context can still sit above the ceiling between
releases — pinning it to the limit would mean compacting on every turn, which
is the thrashing the guard is there to prevent.

Tests: 11 new. Confirmed they catch the defect rather than merely passing
alongside it — reverting the staleness check turns 8 of them red. Coverage
spans the guard holding on an unchanged and a marginally-grown conversation,
releasing on real growth, matching a fresh compactor's output once released,
resetting the streak, agreeing across `needs_compaction()`/`compact()`, the
end-to-end long session, and all three affected strategies.

Verified: `pytest tests/unit -k compact` — 158 passed, 6 failed. Those 6 are
identical to the pre-change baseline on this tree (confirmed by stashing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-09-02 16:37:58 +00:00
Mervin PraisonandClaude Opus 5 b651560f93 fix(bot): bound gateway subprocess calls so a hung command cannot stall the loop (#4677)
* fix(bot): bound gateway subprocess calls so a hung command cannot stall the loop

Four gateway paths shelled out with no timeout. The dispatcher calls them
from inside the asyncio event loop, so a child blocked on stdin — a git
credential prompt, an index.lock, a confirmation prompt — stopped the whole
gateway serving, indefinitely and silently.

Adds praisonai_bot/_guarded_subprocess.run_guarded: a drop-in wrapper that
always bounds the call and, on timeout, returns a CompletedProcess with
returncode 124 and a *diagnosis* in stderr rather than raising. Existing
`returncode == 0` call sites therefore keep working without growing an
exception path.

Two defences, in order:

- stdin is /dev/null and GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=never /
  DEBIAN_FRONTEND=noninteractive are layered under the caller's env, so the
  common cases fail fast instead of blocking. The timeout is a backstop.
- On timeout the partial output is matched against known stall signatures
  and the caller gets an actionable line — "credential prompt", "git lock
  contention", "confirmation prompt, re-run with --yes", "SSH host-key
  prompt" — instead of a bare elapsed time.

Adopted at the four sites that can block the event loop:

- gateway/kanban_dispatcher.py::_run_git — the worst of them, reached from
  dispatch_once and the worktree integration path
- kanban/sqlite_store.py — git rev-parse on the create_task path
- gateway/preflight.py — launchctl on the startup path
- gateway/pairing.py — icacls while hardening the pairing secret

Git keeps a deliberately generous 300s ceiling (PRAISONAI_KANBAN_GIT_TIMEOUT)
because worktree add and merge on a large repository are legitimately slow;
the aim is to bound an indefinite hang, not to fail slow-but-healthy work.
Other commands use 120s (PRAISONAI_SUBPROCESS_TIMEOUT).

Scope is deliberately narrow. The ~66 remaining subprocess.run sites without
timeouts are in daemon/ and CLI paths, where a hang stalls one user command
rather than the gateway; those are worth a separate pass.

Tests: 32 new, covering timeout-without-raising, stdin EOF, each diagnosis
signature, env layering and precedence, unusable env overrides, and that
_run_git routes through the guard.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 625 passed,
22 failed. Those 22 are identical to the pre-change baseline on this tree
(confirmed by stashing); two further files fail to collect because the
optional `websockets` package is absent here. Neither is related to this
change.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

* fix(bot): kill the whole process group on timeout, and surface a timed-out repo probe

Addresses two findings from Greptile's review of this PR.

1. Descendants survived the timeout (P1)

subprocess.run(timeout=) kills only the *direct* child, which is not enough
for the commands this guard wraps — git routinely spawns credential helpers
and transport processes, and a stuck credential helper is the motivating
failure. Confirmed against CPython 3.11's subprocess.run:

  except TimeoutExpired as exc:
      process.kill()
      if _mswindows:
          exc.stdout, exc.stderr = process.communicate()   # no timeout
      else:
          process.wait()

On POSIX the call returns but orphans the grandchild. On Windows that
communicate() has no timeout and blocks until every descendant releases the
inherited stdout/stderr pipe — reintroducing the exact indefinite hang this
module exists to prevent, on a platform the gateway supports.

run_guarded now moves off subprocess.run: it starts the command in its own
process group (start_new_session on POSIX, CREATE_NEW_PROCESS_GROUP on
Windows), kills the whole group on timeout (killpg / taskkill /F /T), then
reaps with a *bounded* second wait. If a descendant somehow survives and
still holds the pipe, the captured output is abandoned with a warning rather
than blocking the caller — returning late is the failure mode being fixed.
The same cleanup runs on BaseException, so a KeyboardInterrupt cannot leave
a live child behind either.

`check` is now handled explicitly (matching subprocess.run) since Popen does
not implement it. A timeout still returns rather than raising, so callers
branching on returncode are unaffected.

2. A timed-out repo probe silently disabled worktree isolation

_is_git_repo answers False on timeout, which is correct — a slow path must
never fail the create — but the caller then downgrades the task to the
shared workspace. Losing worktree isolation silently is an operator-visible
concern, so a timeout (returncode 124) is now distinguished from a genuine
"not a repository" and logged at WARNING with the diagnosis. The return
value is deliberately unchanged.

Tests: 5 new (37 total). Two are real regression tests for the group kill —
one asserts run_guarded returns promptly despite a descendant holding the
pipe, the other asserts the grandchild is actually dead by giving it a
marker file it can only write if it outlived the kill. Plus check= semantics.

Also fixes a syntax error introduced in the previous commit: a backslash
inside an f-string expression, which is illegal before Python 3.12 while
this package supports 3.10+.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 630 passed,
22 failed, matching the pre-change baseline on this tree exactly.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

* fix(bot): reject non-finite timeout overrides that would restore an unbounded wait

From CodeRabbit's review. `float("inf")` parses cleanly and satisfies a bare
`value > 0` check, so PRAISONAI_SUBPROCESS_TIMEOUT=inf (or Infinity, +inf,
1e400) was accepted and handed back as the timeout — silently reinstating the
exact indefinite wait this module exists to remove. Verified:

  >>> float("inf") > 0
  True

Both resolvers now require math.isfinite() as well as a positive value:
_default_timeout in _guarded_subprocess and _git_timeout in the kanban
dispatcher. `nan` already failed the comparison, but is covered explicitly so
the intent is not left to a subtlety of IEEE comparison.

Tests: 11 new (48 total), parametrised over inf/Infinity/+inf/1e400/nan for
both resolvers. Confirmed they catch the bug rather than merely passing —
reverting the isfinite guard turns 4 of them red.

One of the new tests was initially written as run_guarded(..., timeout=0.3)
with the env var set, which would have passed even with the bug present
because an explicit argument bypasses the env path entirely. Rewritten to
assert on the resolver directly.

Also adds class-level docstrings to the test suite, stating the invariant
each group pins. Per-method docstrings restating self-documenting names like
test_hanging_command_returns_instead_of_blocking were deliberately not added.

CodeRabbit's other finding (process-group termination) was already fixed in
40d37c88a; its review ran against the earlier commit c9964dbe0.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 641 passed,
22 failed, matching the pre-change baseline on this tree exactly.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-02 17:24:25 +01:00
praisonai-triage-agent[bot] eef24398ea Merge pull request #4675 from MervinPraison/claude/issue-4635-20260902-1241
fix(mobile): wire the nine unreachable UI modules into main.ts
2026-09-02 14:46:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 4a46a31fca fix(mobile): reopened history reconciles as a prefix; chat-list load failures stay local
Two P1 defects in the #4635 wiring:

- Reopening a chat appended raw <p> rows straight to the transcript while
  render/nodes stayed empty, so the next token reconciled the new turn against
  an empty render and inserted it ABOVE the history, and New chat could not
  remove rows it never keyed. History is now a reconciled PREFIX built with
  stable `history:N` ids, painted through the same reconcile/applyOps path and
  dropped on New chat.
- The chat-list load was a floating `void (async ...)()` with no rejection
  handler; a StoragePort rejection reached the global crash handler and
  replaced the whole app with the fatal screen. It now catches locally and
  paints an in-section alert.

Adds two end-to-end tests: a message sent after reopening lands below the
history, and a storage failure while the chat list loads stays local.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 14:05:48 +00:00
praisonai-triage-agent[bot] 01a7baf7a6 Merge pull request #4676 from MervinPraison/claude/issue-4635-20260902-1303
fix(mobile): wire the nine UI modules into main.ts (fixes #4635)
2026-09-02 14:01:42 +00:00
praisonai-triage-agent[bot] 123d61c529 Merge pull request #4674 from MervinPraison/claude/issue-4673-20260902-1249
fix(mobile): default to in-process engine on device
2026-09-02 13:50:21 +00:00
praisonai-triage-agent[bot]andMervin Praison c0b9803d8e fix(mobile): keep reopened history in reconcile + move route focus
Two P1 defects Greptile flagged on #4676:

- Reopening a chat appended untracked <p> nodes and reset render to
  empty, so the next turn reconciled from nothing and inserted rows
  ABOVE the history, which itself could never be reconciled again.
  History is now built as real Rows and prepended to every reconcile,
  so a follow-up turn lands below it and survives the diff.
- showRoute computed the focus target but never applied it and always
  treated navigation as a push. Focus now moves to the new screen's
  heading, push/pop is derived from stack depth, and Back restores
  focus to the control that opened the screen.

Adds fake-dom focus()/activeElement/isConnected and two mount tests
(send-after-reopen ordering, route-change focus + Back restore).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:27:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 090c06f985 fix(mobile): wire the nine UI modules into main.ts so they are reachable (fixes #4635)
The nine unreachable UI subsystems (composer, settings, i18n locale+bundle,
transcript scroll, a11y focus+names, layout insets, chat list) are now imported
directly by app/src/main.ts rather than only by their own tests. All five
acceptance criteria are met: Settings paints, a conversation can be reopened,
the transcript follows the stream with jump-to-latest, the locale is detected
(making the #4607 RTL fix reachable), and the composer is backed by composer.ts.

Five new end-to-end tests drive the real mount() through the fake DOM,
including "tapping Settings paints the settings screen".

npm run check: typecheck clean, boundaries 142 files no violations, 1300 tests pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:10:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 34a2e3ee13 fix(mobile): keep remote-http as first-launch default until in-process ships
The platform-based default flipped native (tauri) to the in-process
praisonai-ts engine, but that engine's module is absent from the shipping
webview (build-webview.mjs bundles only main.ts; the engine reaches
praisonai-ts via a runtime import outside dist, #4437). On a real device its
first turn rejects "unavailable in this build" with no recovery -- worse than
the "not answering" warning it replaced, and the brick registry.ts keeps the
engine out of the picker to avoid. Platform["kind"] also cannot tell desktop
Tauri (cargo tauri dev) from a phone, so the ternary stranded desktop/dev too.

Default stays remote-http on every platform for now; the exported
defaultEngineIdFor seam is kept for when #4437 makes the engine shippable.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:04:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 1a45f462a9 fix(mobile): default to in-process engine on device (fixes #4673)
A fresh install on a phone defaulted `engineId` to `remote-http` at
`http://127.0.0.1:8765`, which is nothing a device can reach and is a
cleartext localhost address iOS ATS and Android refuse -- so first launch
opened to a "not answering" warning with no way to fix it (Settings is
unreachable, #4635). Default to the in-process `praisonai-ts` engine on
native and keep `remote-http` for desktop/dev; the persisted setting still
wins over either, so this only decides the first launch.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 12:54:46 +00:00
Mervin Praison 2451fa8f81 fix(mobile): an unreachable engine must not stop the app from starting (#4672)
main was RED: `npm run check` exited 1 with 17 failures, all in
app/src/main.test.ts.

#4647 made the health probe a hard boot gate. It added tests for boot.ts,
registry.ts and engine.ts, and never ran main.test.ts -- which mounts the
whole app against a fake HTTP with no /health route. Driving mount() directly:

    no /health handler   ->  "PraisonAI could not start: 404"
    /health says ok      ->  "PraisonAI  New chat  Settings  Send"

That is not only a test problem. On a phone with no desktop engine to reach,
the app now would not open at all -- and the user could not correct the
address, because the address is changed in Settings and Settings is inside
the app that will not start. #4638 asked for a named failure WITH a bounded
retry and warned explicitly that boot must not hang on a dead engine;
`retryable` was computed, carried all the way to boot.ts:167, and then read by
nothing.

The line is drawn where readiness.ts already draws it:

- PERMANENT (`retryable: false` -- a version mismatch) still refuses to boot.
  Waiting will not fix it, so booting into it only defers the same failure to
  the user's first message. The engine is disposed, as before.
- RETRYABLE (transport, unhealthy, malformed) hands the engine over with its
  unreadiness attached. The app starts; main.ts renders a warning notice into
  the transcript and announces it assertively, because the user is about to
  type into something that cannot reply yet. The engine is NOT disposed -- it
  is the one the app will use when the remote comes up.

Everything #4647 was for is kept: the probe still runs at selection, a 200
with {"ok": false} is still not readiness, and the reason still has a name.
Only the verdict on a recoverable failure changed.

Three tests from #4647 asserted the hard gate and are rewritten to the new
contract rather than deleted, each keeping its original intent and gaining the
pair that makes the boundary visible: a permanent unreadiness still refuses, a
retryable one boots and reports, a permanently refused engine is disposed and
a retained one is not until the app is.

1295 tests on Node 22.23 and 24.12. Seven mutations -- including reinstating
the hard gate -- all die.

Refs #4638
2026-09-02 13:23:36 +01:00
praisonai-triage-agent[bot]andMervin Praison f937827235 fix: re-apply engine packages when the requirement drifts (#4668) (#4670)
* fix: re-apply engine packages when the requirement drifts (fixes #4668)

The desktop venv is provisioned once, on first run, and never revisited, so
a library fix shipped in praisonaiagents never reaches existing installs and
raising ENGINE_PACKAGES changes only what new installs get.

Record what was installed in a .praisonai-packages stamp beside the venv, and
on each launch compare it against the current ENGINE_PACKAGES; when they
differ, re-run the single install step (uv is near-instant when nothing
changed) and rewrite the stamp. A missing stamp is treated as drift so
pre-stamp installs get the current set once. The stamp lives inside the venv
so deleting the venv also clears the record. Failure still starts the engine,
letting the UI report the version actually installed.

Co-authored-by: MervinPraison <[email protected]>

* fix: reconcile only the venv we own, and never adopt a stale engine (#4668)

Address the three reviewer findings on package reconciliation:

- Wrong interpreter (Greptile P1): reconcile only when the resolved
  interpreter is data/venv's own python; a PRAISONAI_PYTHON override or
  checkout venv is left untouched instead of installing into (or failing
  against) an unrelated user-data venv.
- Adoption bypass (Greptile P1): when packages were actually installed,
  the survivor is running the old code, so the health probe is forced
  false -- reclaim kills it and clears the lock (PID-reuse guard intact)
  and a fresh engine that loaded the new packages is started.
- Stuck "updating…" label (Greptile P2): the label is now set and
  cleared inside reconcile around the install only, so a no-drift launch,
  a non-owned interpreter, or a later failure never strands the tray.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 13:05:46 +01:00
praisonai-triage-agent[bot]andMervin Praison 7451a62690 feat: add headless CLI to drive the desktop engine (#4671)
* feat: add headless CLI to drive the desktop engine (fixes #4669)

Adds src/praisonai-desktop/cli.py, a stdlib-only driver that speaks to the
same loopback routes the Tauri app does, so desktop bugs can be reproduced
from a terminal, over SSH, or in CI instead of only through the GUI.

- engine start  : run the engine, print the announced port
- engine health : versions, pid, data dir, port (found via the lockfile)
- chat          : one turn streamed to stdout; a tools-ran-but-no-text turn
                  (the 1.7.1 failure) is named rather than shown as blank
- doctor        : installed vs required praisonaiagents, reading the floor
                  from src-tauri/src/provision.rs so it cannot drift

No new engine code and no dependencies: it reuses engine/server.py for the
data dir, lockfile format and version lookup. Adds engine/test_cli.py.

Co-authored-by: MervinPraison <[email protected]>

* fix: isolate CLI chat sessions, answer approval gate, verify engine identity

Addresses reviewer feedback on the headless desktop CLI:
- Send `session` (=chat_id) so each --chat-id gets its own cached agent
  rather than sharing the engine's default session state.
- Answer approval_request over /approve so a tool-enabled turn does not
  block on the gate for the full timeout; --approve to allow, else deny.
- Verify /health protocol version in _live_port so a recycled port answered
  by another loopback service is not adopted as the engine.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 13:05:35 +01:00
praisonai-triage-agent[bot]andMervin Praison d6c7d68e5e test: verify ExecutionConfig.max_rpm wires a live RateLimiter (fixes #3701) (#4666)
The core wiring (agent.py auto-builds RateLimiter from max_rpm, YAML
parser promotes max_rpm to a live limiter) is already present on main.
This closes the remaining verification gaps the issue flagged:

- Strengthen the YAML test so it asserts a live RateLimiter with the
  correct rpm, not just the dead `_yaml_max_rpm` metadata attribute.
- Add a behavioural regression test proving the auto-wired limiter
  actually throttles (injectable clock: 2nd acquire() waits ~60s under
  max_rpm=1), covering the issue's key behavioural acceptance criterion.

No production code change; existing fix confirmed via reproduction.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:38:23 +01:00
praisonai-triage-agent[bot]andMervin Praison d4197e6303 feat: add EvalPort adapter for praisonaiagents.eval (#4667)
* feat: add EvalPort adapter for praisonaiagents.eval (fixes #4275)

Dependency-free framework-neutral interop for the eval package. Adds
to_evalport()/from_evalport() (EvalPackage <-> EvalPort Suite) and
report_to_evalport() (EvalReport -> EvalPort ResultSet), mirroring the
existing harness_eval.py plain-dict pattern. No new dependency on
evalport-sdk; callers validate the emitted dicts with openeval themselves.

Co-authored-by: Mervin Praison <[email protected]>

* fix: prevent EvalPort timeout/metadata round-trip corruption

Emit native timeout_seconds as a dedicated top-level EvalPort field
instead of stuffing it into metadata via setdefault, which silently
clobbered a user-supplied metadata['timeout_seconds'] key and lost the
native value on round trips. Import prefers the top-level field and
falls back to metadata for external suites, without mutating metadata.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:38:08 +01:00
103 changed files with 27339 additions and 1152 deletions

No files matched your search

+66 -2
View File
@@ -26,9 +26,18 @@ name: mobile
on:
push:
branches: [main]
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
paths: &watched
- 'src/praisonai-mobile/**'
- '.github/workflows/mobile.yml'
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
# Watching only praisonai-mobile made that gate fire when the CONSUMER
# changed and stay silent when the thing it watches did -- so upstream
# drift landed on main unchecked. Verified on 71197fa80, which edited
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
- 'src/praisonai-ts/src/**'
pull_request:
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
paths: *watched
concurrency:
group: mobile-${{ github.ref }}
@@ -146,3 +155,58 @@ jobs:
run: cargo test
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
# ------------------------------------------------------------------
# The mobile targets, which nothing compiled.
#
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
# ios targets. Both legs of `rust shell` above are HOST targets, so that
# attribute -- the one that generates the Android JNI symbol and the iOS
# start_app symbol -- expanded exactly zero times in CI, and neither did the
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
# these.
#
# `cargo check`, not `build`: it type-checks and macro-expands under the
# mobile cfg, which is the thing that was never done, and needs no NDK and no
# Xcode project. Verified at 18s for the android target.
# ------------------------------------------------------------------
cross-check:
name: cross-check (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
os: ubuntu-22.04
- target: aarch64-apple-ios
os: macos-15
defaults:
run:
working-directory: src/praisonai-mobile/src-tauri
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
key: ${{ matrix.target }}
# generate_context! expands frontendDist at COMPILE time and dist/ is
# gitignored, so the crate cannot be checked without this -- the same
# reason the job above needs it.
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
npm ci
npm run build
- name: Does the crate still expand and type-check for a phone?
run: cargo check --lib --target ${{ matrix.target }}
+79
View File
@@ -0,0 +1,79 @@
name: Parity Gate
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
#
# Two named checks, so each kind of drift is visible in the checks list:
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
# must be regenerated in the PR when the public surface changes.
# signatures - constructor/method parameters of the curated surfaces in
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
# match (after camelCase/alias/flattening rules) or be waived with a
# reason in waivers.yaml. Stale or expired waivers also fail.
#
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
# This workflow never commits; it only reports.
on:
pull_request:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**'
- 'src/praisonai/praisonai/cli/features/**'
- 'src/praisonai-ts/PARITY.md'
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
- 'src/praisonai-ts/signature-parity.json'
- 'src/praisonai-rust/PARITY.md'
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
- '.github/workflows/parity-gate.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
names:
name: names (exported-symbol tracker up to date)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pyyaml
- name: Tracker files match the sources
run: |
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
exit 1
fi
signatures:
name: signatures (constructor and method parameters)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: pip install pyyaml
- name: Install the TypeScript compiler only
# praisonai-ts's full dependency tree is not needed (and does not
# install cleanly); the extractor needs only the `typescript` package.
run: |
mkdir -p "$RUNNER_TEMP/parity-ts"
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
- name: Signatures match, or are waived with a reason
env:
PYTHONPATH: src/praisonai
run: |
if ! python3 -m praisonai._dev.parity.signatures --check; then
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
exit 1
fi
+5 -1
View File
@@ -6,7 +6,11 @@ on:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/src/**/*.rs'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- '.github/workflows/update-docs-parity.yml'
workflow_dispatch: # Allow manual trigger
+18 -5
View File
@@ -1,21 +1,35 @@
name: Update Feature Parity Tracker
# Regenerates the exported-name parity trackers on push to main and commits them.
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/src/**/*.rs'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
# The generator's own code and the wrapper features it counts. Change
# either and nothing regenerated until an unrelated push landed.
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- 'src/praisonai/praisonai/cli/features/**/*.py'
- '.github/workflows/update-parity-tracker.yml'
permissions:
contents: write
jobs:
update-parity-tracker:
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
# committing a regenerated tracker onto a contributor's branch is not this
# workflow's business, and would fail on a fork besides.
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -36,7 +50,7 @@ jobs:
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_parity_tracker.py
- name: Check for changes
id: check_changes
run: |
@@ -58,4 +72,3 @@ jobs:
commit_user_name: "MervinPraison"
commit_user_email: "[email protected]"
commit_author: "MervinPraison <[email protected]>"
@@ -0,0 +1,73 @@
"""
Meeting Notes Agent
An AI meeting-notes pipeline composed entirely from existing PraisonAI
primitives — no new SDK surface required:
1. AudioAgent.transcribe() -> Whisper speech-to-text
2. Agent (summary) -> structured summary + action items
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
Usage:
python meeting-notes-agent.py path/to/recording.mp3
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
"""
import sys
from praisonaiagents import Agent, AudioAgent
def transcribe_meeting(audio_path: str) -> str:
"""Speech-to-text using the built-in Whisper AudioAgent."""
stt = AudioAgent(llm="openai/whisper-1")
return stt.transcribe(audio_path)
def summarize_meeting(transcript: str) -> str:
"""Summarize decisions, topics, and action items."""
notetaker = Agent(
name="Meeting Notetaker",
instructions=(
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
"1. A concise summary (3-5 sentences).\n"
"2. Key decisions made.\n"
"3. Action items as a checklist with owners when mentioned.\n"
"Only include action items that were actually spoken."
),
)
return notetaker.start(f"Transcript:\n\n{transcript}")
def build_qa_agent(transcript_path: str) -> Agent:
"""Index the transcript for semantic search and cross-meeting Q&A."""
return Agent(
name="Meeting Q&A",
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
knowledge=[transcript_path],
)
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python meeting-notes-agent.py <audio_file>")
sys.exit(1)
audio_path = sys.argv[1]
transcript = transcribe_meeting(audio_path)
transcript_path = "meeting_transcript.txt"
with open(transcript_path, "w", encoding="utf-8") as f:
f.write(transcript)
notes = summarize_meeting(transcript)
print("\n=== Meeting Notes ===\n")
print(notes)
qa = build_qa_agent(transcript_path)
print("\n=== Q&A ===\n")
print(qa.start("What were the main action items and who owns them?"))
if __name__ == "__main__":
main()
+11 -11
View File
@@ -18,32 +18,32 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 56 | 17703 |
| ✅ Agent | 22 | 56 | 17728 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 7 | 3503 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3747 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 33 | 13830 |
| ✅ Bots | 7 | 33 | 13860 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 37698 |
| ✅ CLI | 5 | 123 | 37946 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3292 |
| ✅ Context Management | 16 | 36 | 14073 |
| ✅ Context Management | 16 | 36 | 14090 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2924 |
| ✅ Evaluation | 1 | 8 | 3220 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1621 |
| ✅ Execution | 3 | 4 | 1631 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 83 | 30919 |
| ✅ Gateway | 7 | 85 | 31517 |
| ✅ Guardrails | 4 | 4 | 2288 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4690 |
@@ -61,7 +61,7 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 2011 |
| ✅ Providers | 1 | 56 | 7895 |
| ✅ Providers | 1 | 57 | 8197 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
@@ -70,12 +70,12 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3556 |
| ✅ Security | 1 | 3 | 3529 |
| ✅ Sessions | 4 | 19 | 7644 |
| ✅ Sessions | 4 | 19 | 7648 |
| ✅ Skills | 6 | 16 | 5911 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44104 |
| ✅ Tools | 12 | 145 | 44115 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 6 | 728 |
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
These docs exist but don't match any implemented feature category:
- ℹ️ Database (43 docs, 7269 lines)
- ℹ️ Database (43 docs, 7293 lines)
- ℹ️ Documents (1 docs, 798 lines)
---
@@ -35,18 +35,6 @@ from ..goal.loop import GoalLoopMixin
# Module-level logger for thread safety errors and debugging
logger = get_logger(__name__)
_COMPLETION_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}'
)
_COMPLETION_PATTERNS = (
(re.compile(r'\btask\s+completed?\b'), False),
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True),
(re.compile(r'\bfinished\b'), True),
)
# ============================================================================
# Performance: Lazy imports for heavy dependencies
# Rich, LLM, and display utilities are only imported when needed (output=verbose)
@@ -4917,7 +4917,21 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
try:
# Use the new streaming generator from LLM class
response_content = ""
stream_history = self.chat_history
system_prompt_for_stream = self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
)
# Apply context management (auto-compaction) to the history
# that is actually sent, the same way the non-streaming
# custom-LLM path does. Without this a configured
# ContextManager -- auto-enabled whenever tools are present
# -- is silently skipped on stream=True (Issue #4714).
# Zero overhead when context=False.
stream_history, _stream_context_result = self._apply_context_management(
messages=self.chat_history,
system_prompt=system_prompt_for_stream,
tools=tool_param,
)
durable_context = self._get_durable_run_context()
if durable_context is not None:
stream_history = durable_context.restore_messages(
@@ -4925,10 +4939,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
for chunk in self.llm_instance.get_response_stream(
prompt=actual_prompt,
system_prompt=self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
),
system_prompt=system_prompt_for_stream,
chat_history=stream_history,
temperature=kwargs.get('temperature', 1.0),
tools=tool_param,
@@ -5006,6 +5017,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.chat_history[-1].get("content") == normalized_content):
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Apply context management before the LLM call (auto-compaction),
# exactly as the non-streaming OpenAI path does. Without this a
# configured ContextManager -- auto-enabled whenever tools are
# present -- is silently skipped on stream=True and the full
# history is sent (Issue #4714). Zero overhead when context=False.
system_prompt_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else ""
messages, _stream_context_result = self._apply_context_management(
messages=messages,
system_prompt=system_prompt_content,
tools=tool_param,
)
try:
# Check if OpenAI client is available
if self._openai_client is None:
@@ -139,7 +139,12 @@ class ContextCompactor:
# Anti-thrashing state tracking
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
self._low_savings_streak: int = 0
# Token count at the moment the streak reached its cap. The guard is
# released once the conversation grows materially past this, so a
# compactor that once could not shrink the context is never disabled
# for the rest of the session.
self._low_savings_tokens: int = 0
# Iterative summary state
self._previous_summary: Optional[str] = None
self._previous_summary_global_idx: int = 0
@@ -208,14 +213,56 @@ class ContextCompactor:
- Token count is below threshold
- We've had too many consecutive low-savings attempts
"""
if self.count_total_tokens(messages) <= self.max_tokens:
total_tokens = self.count_total_tokens(messages)
if total_tokens <= self.max_tokens:
return False
# Anti-thrashing: skip if we've had too many low-savings attempts
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
# Anti-thrashing: skip if we've had too many low-savings attempts,
# unless the conversation has grown enough to be worth re-testing.
if self._should_skip_low_savings(total_tokens):
return False
return True
#: Growth factor that releases the anti-thrashing guard. A conversation
#: 25% larger than the one we gave up on is materially different input,
#: so the earlier "not worth compacting" verdict is re-tested.
_LOW_SAVINGS_RETRY_GROWTH = 1.25
def _should_skip_low_savings(self, original_tokens: int) -> bool:
"""Whether the anti-thrashing guard should skip this pass.
The guard stops a compactor that cannot shrink a conversation from
burning a summarisation call every turn. It must not be permanent:
``_low_savings_streak`` is only cleared *after* a compaction actually
runs, so a compactor that trips the cap would otherwise skip forever
and let the context grow without bound -- the precise opposite of what
the guard is for.
Growth past the size we gave up on is new evidence, so the verdict is
retried and the streak reset.
"""
if self._low_savings_streak < self.config.max_consecutive_low_savings:
return False
if self._low_savings_tokens and original_tokens >= (
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
):
self._low_savings_streak = 0
self._low_savings_tokens = 0
return False
return True
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
"""Update anti-thrashing state after a completed compaction pass."""
self._last_savings_pct = savings_pct
if savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
self._low_savings_tokens = 0
return
self._low_savings_streak += 1
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
# Remember the size we gave up at, so growth can release the guard.
self._low_savings_tokens = original_tokens
def compact(
self,
@@ -254,7 +301,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
if self._should_skip_low_savings(original_tokens):
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -323,11 +370,7 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
self._record_savings_outcome(result.savings_pct, original_tokens)
return compacted, result
@@ -365,7 +408,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
if self._should_skip_low_savings(original_tokens):
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -416,11 +459,7 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
self._record_savings_outcome(result.savings_pct, original_tokens)
return compacted, result
@@ -68,6 +68,10 @@ __all__ = [
"EvalReport",
"EvalPackage",
"EvalRunnerProtocol",
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport",
"from_evalport",
"report_to_evalport",
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge",
"JudgeConfig",
@@ -160,6 +164,10 @@ _LAZY_IMPORTS = {
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge": ("praisonaiagents.eval.judge", "Judge"),
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
@@ -0,0 +1,165 @@
"""
EvalPort adapter - framework-neutral interop for the eval package.
Converts PraisonAI's native eval data models to/from the EvalPort open spec
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
another tool's harness, and results can be diffed/aggregated across tools.
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
Design: this module is intentionally **dependency-free**. It emits and consumes
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
as a dependency. Callers who want schema validation can pass the returned dict to
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
Example:
>>> from praisonaiagents.eval import EvalPackage, EvalCase
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
>>> pkg = EvalPackage(name="support", cases=[
... EvalCase(name="refund", input="refund policy?", expected="30 days")
... ], thresholds={"accuracy": 0.9})
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
>>> pkg2.name == pkg.name
True
"""
from typing import Any, Dict, List
from .package import EvalCase, EvalPackage, EvalReport
EVALPORT_SPEC_VERSION = "1.0"
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
"""Map an ``EvalCase`` to an EvalPort test-case dict.
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
keeping round trips lossless for both.
"""
item: Dict[str, Any] = {
"id": case.name,
"input": case.input,
}
if case.expected is not None:
item["expected_output"] = case.expected
if case.criteria:
item["graders"] = list(case.criteria)
if case.timeout_seconds is not None:
item["timeout_seconds"] = case.timeout_seconds
metadata = dict(case.metadata or {})
if metadata:
item["metadata"] = metadata
return item
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
"""Map an EvalPort test-case dict to an ``EvalCase``.
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
without mutating the caller's metadata.
"""
metadata = dict(item.get("metadata") or {})
if "timeout_seconds" in item:
timeout = item["timeout_seconds"]
else:
timeout = metadata.get("timeout_seconds", 30.0)
return EvalCase(
name=item.get("id") or item.get("name") or "case",
input=item.get("input", ""),
expected=item.get("expected_output", item.get("expected")),
criteria=list(item.get("graders") or item.get("criteria") or []),
metadata=metadata,
timeout_seconds=timeout,
)
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
Args:
package: The native PraisonAI eval package.
Returns:
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
"""
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "suite",
"name": package.name,
"description": package.description,
"version": package.version,
"cases": [_case_to_evalport(c) for c in package.cases],
"thresholds": dict(package.thresholds or {}),
"seed": package.seed,
}
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
Args:
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
Returns:
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
``HarnessEvaluator`` as native ``EvalCase`` objects.
"""
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
return EvalPackage(
name=suite.get("name", "suite"),
description=suite.get("description", ""),
version=suite.get("version", "1.0.0"),
cases=cases,
thresholds=dict(suite.get("thresholds") or {}),
seed=suite.get("seed"),
)
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
Args:
report: The aggregated report from running an eval package.
Returns:
A spec-shaped dict suitable for
``openeval.validate.validate_result_set()``.
"""
results: List[Dict[str, Any]] = []
for r in report.results:
item: Dict[str, Any] = {
"case_id": r.case_name,
"passed": r.passed,
"score": r.score,
"latency_ms": r.latency_ms,
}
if r.actual_output is not None:
item["actual_output"] = r.actual_output
if r.error is not None:
item["error"] = r.error
if r.criteria_scores:
item["grader_scores"] = dict(r.criteria_scores)
if r.record is not None:
item["record"] = r.record
results.append(item)
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "result_set",
"suite_name": report.package_name,
"summary": {
"total": report.total_cases,
"passed": report.passed_cases,
"failed": report.failed_cases,
"pass_rate": report.pass_rate,
"average_score": report.average_score,
"thresholds_met": dict(report.thresholds_met or {}),
},
"results": results,
}
@@ -5787,10 +5787,39 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""Set the current agent name for token tracking."""
self.current_agent_name = agent_name
def _resolve_openai_compatible_model(self) -> str:
"""Route a bare model name through the OpenAI-compatible client.
When a custom ``base_url`` is set but the model has no ``provider/``
prefix (e.g. ``deepseek-v4-flash`` against an OpenAI-compatible host),
LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
Prefixing ``openai/`` makes LiteLLM use its OpenAI Chat Completions
client against the supplied ``base_url``. Anthropic/Gemini/Ollama models
are left untouched so their native adapters still apply.
The effective base_url is either the instance ``base_url`` or one
injected by a subscription auth provider (e.g. qwen-cli/codex resolve a
Chat-Completions host at request time). Both paths need routing so a
bare model under subscription auth does not reach LiteLLM unprefixed.
"""
model = self.model
if not isinstance(model, str) or "/" in model:
return model
effective_base_url = self.base_url
if not effective_base_url:
creds = self._resolve_subscription_creds()
if creds and getattr(creds, "base_url", None):
effective_base_url = creds.base_url
if not effective_base_url:
return model
if self._detect_provider() != "openai":
return model
return f"openai/{model}"
def _build_completion_params(self, **override_params) -> Dict[str, Any]:
"""Build parameters for litellm completion calls with all necessary config"""
params = {
"model": self.model,
"model": self._resolve_openai_compatible_model(),
}
# Add optional parameters if they exist
@@ -6102,7 +6131,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
- tools → ``tools`` (Responses API uses same function schema)
- temperature, max_tokens, etc. → direct pass-through
"""
params: Dict[str, Any] = {"model": self.model}
params: Dict[str, Any] = {"model": self._resolve_openai_compatible_model()}
# ── Extract system instructions ─────────────────────────────────
instructions = None
@@ -79,11 +79,17 @@ def is_due(
try:
target = datetime.fromisoformat(sched.at)
if target.tzinfo is None:
target = target.replace(
tzinfo=resolve_schedule_timezone(
sched.tz or default_timezone
)
)
# A naive ``at`` reflects the user's wall clock (they typed it
# from local time), so interpret it in the schedule's timezone:
# the explicit tz / PRAISONAI_SCHEDULE_TIMEZONE when set,
# otherwise the local zone rather than UTC — a naive string in a
# non-UTC zone would otherwise fire an hour (or more) off.
tz = sched.tz or default_timezone
if tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE"):
tzinfo = resolve_schedule_timezone(tz)
else:
tzinfo = datetime.now().astimezone().tzinfo
target = target.replace(tzinfo=tzinfo)
# Evaluate against the caller-supplied ``now`` (not wall-clock) so
# one-shot jobs are deterministic and consistent with every/cron.
return now >= target.timestamp()
@@ -162,6 +162,25 @@ def _clock_to_next_at(hour: int, minute: int, tz: str | None) -> str:
return target.isoformat()
def _cron_schedule(cron_expr: str, tz: str | None) -> Schedule:
"""Build a cron Schedule, refusing if nothing can evaluate it.
``is_due()`` returns False for a cron job when ``croniter`` is missing --
a log line, no error, a job that never runs. A schedule the runtime
cannot evaluate must be rejected here, where the user can still see the
answer, not accepted and ignored. Every natural-language recurring form
("every day at 9am") arrives through this path.
"""
try:
import croniter # noqa: F401 # type: ignore[import-untyped]
except ImportError as exc:
raise ValueError(
f"Schedule {cron_expr!r} needs the cron engine, which is not "
"installed. Install with: pip install croniter"
) from exc
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
"""Parse a schedule expression string into a ``Schedule`` object.
@@ -185,7 +204,7 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
cron_expr = expr[5:].strip()
if not cron_expr:
raise ValueError("Empty cron expression after 'cron:' prefix")
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
return _cron_schedule(cron_expr, tz)
# At prefix (ISO timestamp)
if lower.startswith("at:"):
@@ -231,7 +250,7 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
has_dow = cron_expr.rsplit(" ", 1)[-1] != "*"
recurring = has_dow or bool(re.match(r"(?i)^\s*(every|daily|each)\b", expr))
if recurring:
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
return _cron_schedule(cron_expr, tz)
minute, hour = cron_expr.split(" ")[:2]
at = _clock_to_next_at(int(hour), int(minute), tz)
return Schedule(kind="at", at=at, tz=tz)
+6 -1
View File
@@ -17,7 +17,12 @@ dependencies = [
"openai>=1.68.2",
"posthog>=3.0.0",
"PyYAML>=6.0",
"aiohttp>=3.8.0"
"aiohttp>=3.8.0",
# The scheduler converts every natural-language recurring schedule
# ("every day at 9am", "weekdays at 9am") to cron; without this engine
# those jobs are accepted and silently never fire. Pure Python; pulls
# only python-dateutil.
"croniter>=6.0"
]
[project.optional-dependencies]
@@ -1205,6 +1205,12 @@ steps:
researcher = parser._agents.get('researcher')
assert researcher is not None
assert hasattr(researcher, '_yaml_max_rpm')
assert researcher._yaml_max_rpm == 10
# YAML max_rpm must wire a live RateLimiter (not just store metadata).
from praisonaiagents.llm.rate_limiter import RateLimiter
assert isinstance(researcher._rate_limiter, RateLimiter)
assert researcher._rate_limiter.requests_per_minute == 10
assert researcher.max_rpm == 10
def test_agent_with_max_execution_time(self):
"""Test that agents can have max_execution_time for timeout."""
@@ -0,0 +1,191 @@
"""The anti-thrashing guard must be able to go stale.
``_low_savings_streak`` stops a compactor that cannot shrink a conversation
from burning a summarisation call every turn. The streak is only cleared
*after* a compaction actually runs -- but the guard returns before reaching
that code, so once it latched it could never be released. A long-running
agent that tripped it stopped compacting for the rest of the session and its
context grew without bound, which is the precise opposite of what auto
compaction is for.
These tests pin both halves of the contract: the guard still suppresses
repeat passes on an unchanged conversation, and it releases once the
conversation has grown enough to be worth re-testing.
"""
import pytest
from praisonaiagents.compaction import CompactionConfig, ContextCompactor
def _config(**overrides):
"""A tight budget so a handful of turns is enough to trip the guard."""
base = dict(
max_tokens=200,
target_tokens=150,
preserve_recent=2,
min_savings_pct=10.0,
max_consecutive_low_savings=2,
)
base.update(overrides)
return CompactionConfig(**base)
def _conversation(turns):
"""A highly compactible conversation: repetitive, summarisable turns."""
return [{"role": "system", "content": "sys"}] + [
{"role": "user", "content": f"Please refactor module_{i}.py and keep the public API stable."}
for i in range(turns)
]
def _latched(config, at_tokens):
"""A compactor in the state a long session reaches after two low-yield passes."""
compactor = ContextCompactor(config=config, strategy="summarize")
compactor._low_savings_streak = config.max_consecutive_low_savings
compactor._low_savings_tokens = at_tokens
return compactor
class TestGuardHolds:
"""Anti-thrashing behaviour must be preserved."""
def test_unchanged_conversation_is_still_skipped(self):
"""Re-running on identical input is the thrashing the guard exists to stop."""
config = _config()
messages = _conversation(200)
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(messages))
_, result = compactor.compact(messages)
assert result.was_skipped_due_to_low_savings
assert result.messages_removed == 0
def test_marginal_growth_is_still_skipped(self):
"""Growth below the retry threshold is not new evidence.
A couple of extra turns does not justify re-running a summarisation
that just failed to pay for itself; without this the release would
fire on essentially every turn.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(205))
assert result.was_skipped_due_to_low_savings
class TestGuardReleases:
"""A materially larger conversation is new evidence."""
def test_growth_releases_the_guard(self):
"""Past the threshold the verdict is retried, and the retry pays off."""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(300))
assert not result.was_skipped_due_to_low_savings
assert result.messages_removed > 0
# And it must actually shrink -- a released guard that yields nothing
# would mean the release is cosmetic.
assert result.compacted_tokens < result.original_tokens
def test_release_matches_a_fresh_compactor(self):
"""The clearest statement of the bug.
A latched compactor used to hand back untouched the very input an
otherwise-identical fresh one shrinks by ~96%. Once released the two
must be indistinguishable.
"""
config = _config()
messages = _conversation(300)
probe = ContextCompactor(config=config, strategy="summarize")
latched = _latched(config, probe.count_total_tokens(_conversation(200)))
fresh = ContextCompactor(config=config, strategy="summarize")
_, latched_result = latched.compact(messages)
_, fresh_result = fresh.compact(messages)
assert latched_result.compacted_tokens == fresh_result.compacted_tokens
def test_streak_resets_so_the_full_allowance_returns(self):
"""Releasing must clear the streak, not merely skip the check once.
A release that left the streak at the cap would re-latch on the next
call, turning the fix into a one-shot reprieve.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
compactor.compact(_conversation(300))
assert compactor._low_savings_streak < config.max_consecutive_low_savings
def test_needs_compaction_agrees_with_compact(self):
"""Both gates must release together.
``needs_compaction()`` is the public gate callers consult. If it kept
reporting False the release would never be reached in the agent's real
code path, and fixing ``compact()`` alone would be dead code.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
assert compactor.needs_compaction(_conversation(300)) is True
def test_needs_compaction_still_declines_an_unchanged_conversation(self):
"""The other half of that agreement: both gates must also hold together."""
config = _config()
messages = _conversation(200)
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(messages))
assert compactor.needs_compaction(messages) is False
class TestLongSession:
"""End-to-end: the failure this guard bug actually caused."""
def test_compaction_keeps_engaging_across_a_long_session(self):
"""No white-box setup: drive a real session and watch compaction die.
This is the failure a user actually hits, reproduced only through the
public API, so it stands even if the guard's internals are reworked.
"""
config = CompactionConfig(max_tokens=300, target_tokens=200, preserve_recent=2)
compactor = ContextCompactor(config=config, strategy="summarize")
messages = [{"role": "system", "content": "You are a coding agent."}]
events = 0
for turn in range(1, 41):
messages.extend([
{"role": "user", "content": f"Step {turn}: refactor module_{turn}.py, keep the API stable."},
{"role": "assistant", "content": f"Done: module_{turn}.py refactored, helper_{turn}() extracted."},
])
compacted, result = compactor.compact(messages)
messages[:] = compacted
if result.messages_removed:
events += 1
# Before the fix this latched at 4 events and then never fired again,
# letting the context grow unbounded for the rest of the session.
assert events > 4, f"compaction stopped engaging after {events} events"
@pytest.mark.parametrize("strategy", ["summarize", "truncate", "sliding"])
def test_guard_release_is_strategy_independent(strategy):
"""The guard lives above strategy selection, so it must not favour one."""
config = _config()
probe = ContextCompactor(config=config, strategy=strategy)
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(300))
assert not result.was_skipped_due_to_low_savings
@@ -0,0 +1,250 @@
"""Unit tests for eval/evalport.py - EvalPort framework-neutral adapter.
Covers the dependency-free round-trip between PraisonAI eval models and the
EvalPort spec dicts. A real agentic test (gated by RUN_LIVE_TESTS=1) runs a live
Agent and exports its EvalReport as an EvalPort ResultSet.
"""
import os
import pytest
from praisonaiagents.eval import (
EvalCase,
EvalPackage,
EvalReport,
EvalResult,
from_evalport,
report_to_evalport,
to_evalport,
)
class TestEvalPortImports:
"""Adapter functions must be importable from praisonaiagents.eval."""
def test_public_imports(self):
assert callable(to_evalport)
assert callable(from_evalport)
assert callable(report_to_evalport)
class TestSuiteConversion:
"""EvalPackage <-> EvalPort Suite."""
def _pkg(self):
return EvalPackage(
name="support_agent_eval",
description="Support agent suite",
version="2.1.0",
cases=[
EvalCase(
name="refund_policy",
input="What's your refund policy?",
expected="30 days, unused, receipt required",
criteria=["answer_correct"],
metadata={"tier": "gold"},
timeout_seconds=15.0,
)
],
thresholds={"accuracy": 0.9},
seed=7,
)
def test_to_evalport_shape(self):
suite = to_evalport(self._pkg())
assert suite["kind"] == "suite"
assert suite["name"] == "support_agent_eval"
assert suite["version"] == "2.1.0"
assert suite["thresholds"] == {"accuracy": 0.9}
assert suite["seed"] == 7
assert "evalport_version" in suite
case = suite["cases"][0]
assert case["id"] == "refund_policy"
assert case["input"] == "What's your refund policy?"
assert case["expected_output"] == "30 days, unused, receipt required"
assert case["graders"] == ["answer_correct"]
assert case["metadata"]["tier"] == "gold"
assert case["timeout_seconds"] == 15.0
assert "timeout_seconds" not in case["metadata"]
def test_from_evalport_shape(self):
suite = to_evalport(self._pkg())
pkg = from_evalport(suite)
assert isinstance(pkg, EvalPackage)
assert pkg.name == "support_agent_eval"
assert pkg.version == "2.1.0"
assert pkg.thresholds == {"accuracy": 0.9}
assert pkg.seed == 7
case = pkg.cases[0]
assert isinstance(case, EvalCase)
assert case.name == "refund_policy"
assert case.input == "What's your refund policy?"
assert case.expected == "30 days, unused, receipt required"
assert case.criteria == ["answer_correct"]
assert case.metadata["tier"] == "gold"
assert case.timeout_seconds == 15.0
def test_round_trip_preserves_dict(self):
pkg = self._pkg()
round_tripped = from_evalport(to_evalport(pkg))
assert round_tripped.to_dict() == pkg.to_dict()
def test_external_suite_minimal_fields(self):
"""A suite from another tool with only required fields still imports."""
external = {
"name": "hub_suite",
"cases": [{"id": "c1", "input": "hi"}],
}
pkg = from_evalport(external)
assert pkg.name == "hub_suite"
assert len(pkg.cases) == 1
assert pkg.cases[0].name == "c1"
assert pkg.cases[0].input == "hi"
assert pkg.cases[0].expected is None
def test_case_without_expected_or_criteria(self):
pkg = EvalPackage(name="p", cases=[EvalCase(name="c", input="x")])
case = to_evalport(pkg)["cases"][0]
assert "expected_output" not in case
assert "graders" not in case
def test_metadata_timeout_key_does_not_corrupt_native_timeout(self):
"""A user ``metadata['timeout_seconds']`` must not clobber the native one."""
pkg = EvalPackage(
name="p",
cases=[
EvalCase(
name="c",
input="x",
metadata={"timeout_seconds": 999},
timeout_seconds=15.0,
)
],
)
case = to_evalport(pkg)["cases"][0]
assert case["timeout_seconds"] == 15.0
assert case["metadata"]["timeout_seconds"] == 999
round_tripped = from_evalport(to_evalport(pkg)).cases[0]
assert round_tripped.timeout_seconds == 15.0
assert round_tripped.metadata["timeout_seconds"] == 999
def test_external_suite_metadata_timeout_fallback(self):
"""External suites that only put timeout in metadata still import it."""
external = {
"name": "hub",
"cases": [{"id": "c1", "input": "hi", "metadata": {"timeout_seconds": 45}}],
}
case = from_evalport(external).cases[0]
assert case.timeout_seconds == 45
assert case.metadata["timeout_seconds"] == 45
class TestResultSetConversion:
"""EvalReport -> EvalPort ResultSet."""
def _report(self):
return EvalReport(
package_name="support_agent_eval",
total_cases=2,
passed_cases=1,
failed_cases=1,
average_score=0.6,
thresholds_met={"accuracy": False},
results=[
EvalResult(
case_name="refund_policy",
passed=True,
score=0.95,
actual_output="30 days.",
latency_ms=120.0,
criteria_scores={"answer_correct": 0.95},
),
EvalResult(
case_name="shipping",
passed=False,
score=0.25,
error="timeout",
latency_ms=300.0,
),
],
)
def test_result_set_shape(self):
rs = report_to_evalport(self._report())
assert rs["kind"] == "result_set"
assert rs["suite_name"] == "support_agent_eval"
assert rs["summary"]["total"] == 2
assert rs["summary"]["passed"] == 1
assert rs["summary"]["failed"] == 1
assert rs["summary"]["pass_rate"] == 0.5
assert rs["summary"]["average_score"] == 0.6
assert rs["summary"]["thresholds_met"] == {"accuracy": False}
assert len(rs["results"]) == 2
def test_per_case_fields(self):
rs = report_to_evalport(self._report())
passed, failed = rs["results"]
assert passed["case_id"] == "refund_policy"
assert passed["passed"] is True
assert passed["score"] == 0.95
assert passed["latency_ms"] == 120.0
assert passed["actual_output"] == "30 days."
assert passed["grader_scores"] == {"answer_correct": 0.95}
assert "error" not in passed
assert failed["case_id"] == "shipping"
assert failed["passed"] is False
assert failed["error"] == "timeout"
assert "actual_output" not in failed
assert "grader_scores" not in failed
@pytest.mark.skipif(
os.environ.get("RUN_LIVE_TESTS") != "1",
reason="Real agentic test requires RUN_LIVE_TESTS=1 and an API key",
)
class TestEvalPortRealAgent:
"""Real agentic test: run a live Agent, export the report as EvalPort."""
def test_agent_report_exports_to_evalport(self):
from praisonaiagents import Agent
from praisonaiagents.eval import HarnessEvaluator
agent = Agent(
name="math",
instructions="You answer math questions with only the number.",
)
answer = agent.start("What is 2 + 2?")
assert answer is not None
trace = {
"tool_calls": [],
"artifacts": [],
"judge_score": 10.0 if "4" in str(answer) else 0.0,
}
result = HarnessEvaluator(trace=trace, name="math_addition").run()
report = EvalReport(
package_name="math_eval",
total_cases=1,
passed_cases=1 if result.passed else 0,
failed_cases=0 if result.passed else 1,
average_score=result.score,
results=[
EvalResult(
case_name="math_addition",
passed=result.passed,
score=result.score,
actual_output=str(answer),
)
],
)
result_set = report_to_evalport(report)
assert result_set["kind"] == "result_set"
assert result_set["suite_name"] == "math_eval"
assert result_set["results"][0]["case_id"] == "math_addition"
assert result_set["results"][0]["actual_output"] == str(answer)
@@ -0,0 +1,87 @@
"""
Tests for OpenAI-compatible routing of a bare model name + custom base_url.
A Chat Completions host (e.g. https://api.pzero.studio/v1) exposes catalog
model ids like ``deepseek-v4-flash`` with no ``provider/`` prefix. Without a
prefix LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
The LLM class should route such a bare model through the OpenAI-compatible
client by prefixing ``openai/`` at request-build time when a base_url is set.
"""
import pytest
from praisonaiagents.llm.llm import LLM
def test_bare_model_with_base_url_routes_openai():
llm = LLM(
model="deepseek-v4-flash",
base_url="https://api.pzero.studio/v1",
api_key="test-key",
)
params = llm._build_completion_params()
assert params["model"] == "openai/deepseek-v4-flash"
assert params["base_url"] == "https://api.pzero.studio/v1"
assert params["api_key"] == "test-key"
def test_prefixed_model_with_base_url_unchanged():
llm = LLM(
model="openai/gpt-4o",
base_url="https://custom.api.com/v1",
)
params = llm._build_completion_params()
assert params["model"] == "openai/gpt-4o"
def test_bare_model_without_base_url_unchanged():
llm = LLM(model="gpt-4o")
params = llm._build_completion_params()
assert params["model"] == "gpt-4o"
def test_anthropic_model_with_base_url_not_reprefixed():
llm = LLM(
model="claude-3-5-sonnet",
base_url="https://proxy.example.com/v1",
)
params = llm._build_completion_params()
assert params["model"] == "claude-3-5-sonnet"
def test_ollama_base_url_not_reprefixed():
llm = LLM(
model="llama3",
base_url="http://localhost:11434",
)
params = llm._build_completion_params()
assert params["model"] == "llama3"
def test_bare_model_with_subscription_base_url_routes_openai():
"""A subscription auth provider (e.g. qwen-cli/codex) injects an
OpenAI-compatible base_url at request time. A bare model must still be
routed through ``openai/`` even though the instance ``base_url`` is empty.
"""
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
llm = LLM(model="qwen3-coder-plus")
llm._auth_provider_id = "qwen-cli"
llm._cached_subscription_creds = SubscriptionCredentials(
api_key="test-token",
base_url="https://portal.qwen.ai/v1",
)
params = llm._build_completion_params()
assert params["model"] == "openai/qwen3-coder-plus"
assert params["base_url"] == "https://portal.qwen.ai/v1"
def test_bare_model_no_subscription_base_url_unchanged():
"""Subscription creds without a base_url must not trigger prefixing."""
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
llm = LLM(model="gpt-4o")
llm._auth_provider_id = "some-provider"
llm._cached_subscription_creds = SubscriptionCredentials(api_key="test-token")
params = llm._build_completion_params()
assert params["model"] == "gpt-4o"
@@ -380,6 +380,35 @@ class TestMaxRpmAutoWiresRateLimiter:
execution=ExecutionConfig(max_rpm=-1, rate_limiter=custom),
)
def test_auto_wired_limiter_actually_throttles(self):
"""The auto-built limiter must space requests, not just exist.
Uses an injectable clock/sleep so no real time passes: with
max_rpm=1 and burst=1, the second acquire() must wait ~60s.
"""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(max_rpm=1),
)
limiter = agent._rate_limiter
clock = [0.0]
slept = []
def fake_sleep(seconds):
slept.append(seconds)
clock[0] += seconds
limiter._get_time = lambda: clock[0]
limiter._sleep = fake_sleep
limiter.reset()
limiter.acquire() # consumes the single burst token, no wait
assert slept == []
limiter.acquire() # must wait ~60s for the next token at 1 rpm
assert slept, "second acquire() should have blocked under max_rpm=1"
assert abs(sum(slept) - 60.0) < 1e-6
class TestLegacyKwargsCompatibility:
"""Signature-reduction backward-compat: keyword-only guard, internal
@@ -0,0 +1,69 @@
"""Cron schedules must actually fire, and must be refused when they cannot.
``croniter`` was declared in none of the nine pyproject.toml files, so on a
default install every cron-kind schedule -- and every natural-language
recurring one, since "every day at 9am" is converted to cron -- was accepted,
persisted, and returned False from is_due() forever. No error at creation; a
warning in a log nobody reads; a job that never runs.
Three guards, one per layer of the failure:
* the dependency is declared, so a fresh install has the engine;
* a cron job genuinely becomes due, proving the runtime path end to end;
* creation refuses a cron schedule when the engine is absent, so the
failure is loud at the one moment the user can act on it.
"""
import pathlib
import sys
import pytest
from praisonaiagents.scheduler.due import is_due
from praisonaiagents.scheduler.models import ScheduleJob
from praisonaiagents.scheduler.parser import parse_schedule
_PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml"
EIGHT_DAYS = 8 * 86400 # covers daily AND weekly ("every monday") schedules
def _job(expr: str) -> ScheduleJob:
return ScheduleJob(name="t", schedule=parse_schedule(expr), message="hi")
def test_cron_engine_is_a_declared_dependency():
"""Packaging, not code, was the defect; pin the packaging."""
text = _PYPROJECT.read_text()
deps = text[text.index("dependencies = ["):]
deps = deps[: deps.index("]")]
assert "croniter" in deps, "croniter missing from core dependencies"
@pytest.mark.parametrize("expr", [
"every day at 9am",
"weekdays at 9am",
"every monday 9am",
"cron:0 9 * * *",
])
def test_recurring_schedule_actually_becomes_due(expr):
"""The effect: eight days after creation, daily and weekly jobs are due."""
job = _job(expr)
assert job.schedule.kind == "cron", "these must route through cron"
assert is_due(job, job.created_at + EIGHT_DAYS), (
f"{expr!r} was accepted but never becomes due"
)
def test_interval_and_one_shot_do_not_need_the_engine(monkeypatch):
"""Do not over-restrict: 'hourly' and 'in 20 minutes' never used cron."""
monkeypatch.setitem(sys.modules, "croniter", None)
for expr in ("hourly", "*/30m", "in 20 minutes"):
job = _job(expr)
assert is_due(job, job.created_at + EIGHT_DAYS)
def test_cron_schedule_is_refused_when_the_engine_is_missing(monkeypatch):
"""Loud at creation. Setting the module to None makes import raise."""
monkeypatch.setitem(sys.modules, "croniter", None)
with pytest.raises(ValueError, match="cron engine"):
parse_schedule("every day at 9am")
with pytest.raises(ValueError, match="cron engine"):
parse_schedule("cron:0 9 * * *")
@@ -0,0 +1,137 @@
"""The streaming entry point must run context compaction (Issue #4714).
Agent auto-enables a ContextManager whenever tools are present (auto_compact,
compact_threshold 0.8). The non-streaming ``chat()`` path runs
``_apply_context_management`` between building the messages and issuing the
request; ``start(stream=True)`` built the same messages and sent them
unmodified, so a long session streamed the entire history to the model every
turn while the manager sat idle. The desktop's default agent has tools, so it
hit this on every streamed turn.
These tests stub the transport and assert the EFFECT: the message list that
actually reaches the transport is bounded when a ContextManager is configured,
and untouched when it is not.
"""
import pytest
from praisonaiagents import Agent
SEEDED = 400 # ~160k estimated tokens; well past 80% of gpt-4o-mini's 128k window
FILLER = "lorem ipsum dolor sit amet " * 60
def _add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
class _RecordingOpenAI:
"""A proxy over the real client wrapper: everything (build_messages, tool
formatting, ...) is delegated; only the transport call is intercepted and
recorded. ``iter([])`` is an empty stream -- no answer, no tool calls."""
def __init__(self, real):
self._real = real
self.calls = []
outer = self
class _Completions:
def create(self, **kw):
outer.calls.append(kw)
return iter([])
class _Chat:
completions = _Completions()
class _Sync:
chat = _Chat()
self.sync_client = _Sync()
def __getattr__(self, name):
return getattr(self._real, name)
def _seed_history(agent, n=SEEDED):
for i in range(n):
agent.chat_history.append({
"role": "user" if i % 2 == 0 else "assistant",
"content": f"msg {i} {FILLER}",
})
def _stub_openai(agent):
from praisonaiagents.llm import get_openai_client
stub = _RecordingOpenAI(get_openai_client(api_key="sk-test"))
agent._Agent__openai_client = stub # the lazily-built client, pre-seeded
return stub
@pytest.fixture(autouse=True)
def _api_key(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
def test_openai_stream_compacts_long_history():
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add])
assert agent.context_manager is not None, "tools present must auto-enable a ContextManager"
_seed_history(agent)
stub = _stub_openai(agent)
"".join(agent.start("x", stream=True))
assert stub.calls, "the stub transport was never called"
sent = stub.calls[0]["messages"]
# Far fewer than seeded: compaction at threshold 0.8 keeps the window
# under budget, not merely one message under the seed count.
assert len(sent) <= SEEDED * 0.7, (
f"streamed request carried {len(sent)} messages for {SEEDED} seeded; "
"context compaction did not run on the streaming path"
)
# Compaction must not lose the request itself.
assert sent[0]["role"] == "system"
assert sent[-1] == {"role": "user", "content": "x"}
def test_openai_stream_without_context_manager_sends_full_history():
"""Zero-overhead contract: context=False leaves the streamed request alone.
Also pins that the bounded count above is compaction and not some other
trimming in the streaming path."""
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add], context=False)
assert agent.context_manager is None
_seed_history(agent)
stub = _stub_openai(agent)
"".join(agent.start("x", stream=True))
sent = stub.calls[0]["messages"]
assert len(sent) == SEEDED + 2 # system + seeded history + new user turn
def test_custom_llm_stream_compacts_long_history():
"""The LiteLLM branch passes chat_history rather than a built messages list;
compaction must apply to the history that is actually sent."""
agent = Agent(name="t", instructions="x", llm="openai/gpt-4o-mini", tools=[_add])
assert agent.context_manager is not None
_seed_history(agent)
seen = {}
def fake_stream(**kw):
seen.update(kw)
yield "ok"
agent.llm_instance.get_response_stream = fake_stream
"".join(agent.start("x", stream=True))
assert "chat_history" in seen, "the fake transport was never called"
history = seen["chat_history"]
assert len(history) <= SEEDED * 0.7, (
f"streamed custom-LLM request carried {len(history)} history messages for "
f"{SEEDED} seeded; context compaction did not run on the streaming path"
)
# The turn just appended must survive compaction.
assert history[-1] == {"role": "user", "content": "x"}
+19 -5
View File
@@ -850,6 +850,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/51/a2eba2f9ea7d40ddd69d533eeacf835b27946f32204705ae3333bb61b118/crawl4ai-0.8.5-py3-none-any.whl", hash = "sha256:058ff53792e238714bad0045303cade859514f4bcaea8887acb0a190c6798654", size = 500303, upload-time = "2026-03-18T03:34:22.117Z" },
]
[[package]]
name = "croniter"
version = "6.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
@@ -1008,7 +1020,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -3115,6 +3127,7 @@ version = "1.7.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "croniter" },
{ name = "openai" },
{ name = "posthog" },
{ name = "pydantic" },
@@ -3227,6 +3240,7 @@ requires-dist = [
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
{ name = "croniter", specifier = ">=6.0" },
{ name = "dakera", marker = "extra == 'dakera'", specifier = ">=0.12.8" },
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
@@ -4367,7 +4381,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@@ -4430,7 +4444,7 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -4694,8 +4708,8 @@ name = "standard-aifc"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts" },
{ name = "standard-chunk" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
wheels = [
@@ -0,0 +1,311 @@
"""Subprocess execution that cannot hang the caller indefinitely.
Several gateway paths shell out while an asyncio event loop is waiting on
them. ``subprocess.run`` without ``timeout=`` blocks forever if the child
waits on stdin -- a git credential prompt, an ``index.lock`` contention, a
confirmation prompt -- and the whole gateway stops serving.
``subprocess.run(timeout=...)`` alone is not enough, because it only kills
the *direct* child:
* On POSIX its timeout path calls ``kill()`` then ``wait()``. The call
returns, but any grandchild the command spawned is orphaned and keeps
running.
* On Windows it calls ``kill()`` then ``communicate()`` with **no timeout**.
A surviving grandchild that inherited the captured stdout/stderr pipe holds
it open, so that ``communicate()`` blocks until the grandchild exits --
reintroducing exactly the indefinite hang this module exists to prevent.
That is not hypothetical for the calls here: ``git`` routinely spawns
credential helpers and transport processes, and a stuck credential helper is
the motivating failure.
:func:`run_guarded` therefore runs the command in its own process group (or
Windows process group), kills the whole group on timeout, and then reaps
with a *bounded* second wait so a stubborn descendant can never block the
caller.
Note on semantics: this is a total-runtime cap, not an inactivity timer. A
command that streams output steadily for longer than the cap is still killed.
Callers with legitimately long work should pass an explicit ``timeout``.
"""
from __future__ import annotations
import logging
import math
import os
import signal
import subprocess
from typing import Mapping, Optional, Sequence
__all__ = ["run_guarded", "DEFAULT_TIMEOUT", "TIMEOUT_RETURNCODE", "diagnose"]
logger = logging.getLogger(__name__)
#: Total seconds a guarded command may run before it is killed.
#: Override per call, or globally via ``PRAISONAI_SUBPROCESS_TIMEOUT``.
DEFAULT_TIMEOUT = 120.0
#: Returncode reported for a killed command. 124 is the conventional exit
#: status used by coreutils ``timeout``.
TIMEOUT_RETURNCODE = 124
#: Seconds allowed for the post-kill reap. Bounded on purpose: if a
#: descendant somehow survives the group kill and still holds the pipe, we
#: abandon the output rather than block the caller.
REAP_TIMEOUT = 5.0
_ENV_TIMEOUT = "PRAISONAI_SUBPROCESS_TIMEOUT"
# Substrings that identify why a child process is sitting idle. Ordered most
# specific first; the first hit wins.
_SIGNATURES: tuple[tuple[tuple[str, ...], str], ...] = (
(
("username for", "password for", "passphrase", "authentication failed"),
"DETECTED: credential prompt. The command is waiting for a login that "
"will never arrive. Configure a credential helper, or set "
"GIT_TERMINAL_PROMPT=0 so it fails fast instead of blocking.",
),
(
("index.lock", "unable to create", "another git process"),
"DETECTED: git lock contention. Another git process holds the "
"repository lock. Retry once it exits, or remove a stale .git/index.lock.",
),
(
("[y/n]", "(yes/no)", "are you sure", "continue?", "overwrite?", "proceed?"),
"DETECTED: confirmation prompt. Re-run non-interactively with the "
"command's own flag (--yes, -y, --force, --no-confirm).",
),
(
("host key", "fingerprint", "known_hosts"),
"DETECTED: SSH host-key prompt. Pre-populate known_hosts, or pass "
"-o StrictHostKeyChecking=accept-new.",
),
)
_GENERIC = (
"No interactive prompt was detected. The command may be genuinely slow, "
"blocked on network I/O, or deadlocked. Raise timeout= if the work is "
"expected to take longer."
)
def diagnose(captured: str) -> str:
"""Return a human- and model-actionable reason a command stalled.
``captured`` is whatever the child emitted before it was killed; it may be
empty, which is itself common for a process blocked on stdin.
"""
haystack = (captured or "").lower()
for needles, message in _SIGNATURES:
if any(needle in haystack for needle in needles):
return message
return _GENERIC
def _default_timeout() -> float:
"""Resolve the global timeout, ignoring an unusable env override.
``inf`` is rejected alongside zero and negatives: it parses cleanly and is
positive, so a bare ``value > 0`` check would accept it and hand back an
unbounded wait -- reinstating the exact hang this module prevents. ``nan``
fails the comparison on its own, but is covered explicitly for clarity.
"""
raw = os.environ.get(_ENV_TIMEOUT)
if not raw:
return DEFAULT_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT
if not math.isfinite(value) or value <= 0:
return DEFAULT_TIMEOUT
return value
def _non_interactive_env(env: Optional[Mapping[str, str]]) -> dict:
"""Environment that makes common tools fail rather than prompt.
Suppressing the prompt at source is better than killing the process after
it hangs: the command returns an error the caller can act on immediately.
"""
resolved = dict(env if env is not None else os.environ)
resolved.setdefault("GIT_TERMINAL_PROMPT", "0")
resolved.setdefault("GCM_INTERACTIVE", "never")
resolved.setdefault("DEBIAN_FRONTEND", "noninteractive")
return resolved
def _kill_process_group(proc: subprocess.Popen) -> None:
"""Terminate the command *and everything it spawned*.
Killing only the direct child leaves grandchildren holding the inherited
stdout/stderr pipe, which is what turns a bounded call back into a hang.
"""
if os.name == "nt":
try:
# /T walks the tree, /F forces. Bounded so the cleanup itself
# cannot become the new hang.
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True,
timeout=REAP_TIMEOUT,
)
except Exception: # pragma: no cover - Windows-only path
_kill_direct(proc)
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
# Already gone, or we could not resolve the group; fall back.
_kill_direct(proc)
def _kill_direct(proc: subprocess.Popen) -> None:
"""Last-resort kill of the direct child only."""
try:
proc.kill()
except Exception: # pragma: no cover - process already reaped
pass
def _reap(proc: subprocess.Popen) -> tuple[str, str]:
"""Collect whatever output is available without blocking indefinitely."""
try:
return proc.communicate(timeout=REAP_TIMEOUT)
except subprocess.TimeoutExpired:
# A descendant survived the group kill and still holds the pipe.
# Abandon the output rather than block the caller -- returning late
# is the failure mode this module exists to prevent.
logger.warning(
"Guarded command %s left a descendant holding its output pipe; "
"abandoning captured output after %.0fs.",
proc.args,
REAP_TIMEOUT,
)
for stream in (proc.stdout, proc.stderr):
try:
if stream is not None:
stream.close()
except Exception: # pragma: no cover - best effort
pass
return "", ""
except Exception: # pragma: no cover - defensive
return "", ""
def run_guarded(
cmd: Sequence[str],
*,
timeout: Optional[float] = None,
cwd: Optional[str] = None,
env: Optional[Mapping[str, str]] = None,
interactive: bool = False,
check: bool = False,
text: bool = True,
capture_output: bool = True,
**kwargs,
) -> subprocess.CompletedProcess:
"""Run ``cmd`` with a bounded runtime, never raising on timeout.
Behaves like ``subprocess.run(..., capture_output=True, text=True)``. On
timeout the whole process group is killed and a
:class:`subprocess.CompletedProcess` is returned with returncode
:data:`TIMEOUT_RETURNCODE` and a diagnosis appended to stderr, so existing
``returncode == 0`` checks keep working instead of having to grow a new
exception path.
Args:
cmd: Command and arguments.
timeout: Total seconds allowed. Defaults to
``PRAISONAI_SUBPROCESS_TIMEOUT`` or :data:`DEFAULT_TIMEOUT`.
cwd: Working directory.
env: Environment; prompt-suppressing defaults are layered underneath.
interactive: Set True only for a command that must reach a terminal.
Skips prompt suppression and stdin redirection; the timeout and
the group kill still apply.
check: Raise ``CalledProcessError`` on a non-zero exit, matching
``subprocess.run``. A timeout still returns rather than raising.
text: Decode output as text.
capture_output: Capture stdout and stderr.
**kwargs: Forwarded to ``subprocess.Popen``.
"""
resolved_timeout = _default_timeout() if timeout is None else timeout
if capture_output:
kwargs.setdefault("stdout", subprocess.PIPE)
kwargs.setdefault("stderr", subprocess.PIPE)
# stdin at /dev/null turns "wait forever for input" into an immediate EOF
# for tools that honour it, so the timeout is a backstop rather than the
# primary defence.
if not interactive:
kwargs.setdefault("stdin", subprocess.DEVNULL)
# Isolate the command so the whole tree can be killed together.
if os.name == "nt":
kwargs["creationflags"] = (
kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
kwargs.setdefault("start_new_session", True)
argv = list(cmd)
proc = subprocess.Popen(
argv,
cwd=cwd,
env=env if interactive else _non_interactive_env(env),
text=text,
**kwargs,
)
try:
stdout, stderr = proc.communicate(timeout=resolved_timeout)
except subprocess.TimeoutExpired:
_kill_process_group(proc)
stdout, stderr = _reap(proc)
stdout = _as_text(stdout)
stderr = _as_text(stderr)
# Built outside the f-string: a backslash inside an f-string
# expression is a syntax error before Python 3.12, and this package
# supports 3.10+.
captured = stdout + "\n" + stderr
detail = (
f"Command killed after {resolved_timeout:g}s: {' '.join(argv)}\n"
f"{diagnose(captured)}"
)
return subprocess.CompletedProcess(
args=argv,
returncode=TIMEOUT_RETURNCODE,
stdout=stdout,
stderr=(stderr + "\n" + detail).strip(),
)
except BaseException:
# Never leave a running child behind on an unexpected error, including
# KeyboardInterrupt.
_kill_process_group(proc)
_reap(proc)
raise
result = subprocess.CompletedProcess(
args=argv,
returncode=proc.returncode,
stdout=_as_text(stdout) if capture_output else stdout,
stderr=_as_text(stderr) if capture_output else stderr,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, argv, result.stdout, result.stderr
)
return result
def _as_text(value) -> str:
"""Normalise stdout/stderr, which may be bytes, None, or already str."""
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value)
@@ -8,14 +8,42 @@ system and follows Hermes patterns for worker management.
import asyncio
import logging
import math
import os
import re
import subprocess
import time
from typing import Dict, Any, List, Optional
from .._guarded_subprocess import run_guarded
logger = logging.getLogger(__name__)
# Ceiling for a single git invocation. Worktree add, merge and commit on a
# large repository are legitimately slow, so this is set well above the
# general default: the aim is to bound an indefinite hang, not to fail slow
# work. Override with PRAISONAI_KANBAN_GIT_TIMEOUT (seconds).
_DEFAULT_GIT_TIMEOUT = 300.0
def _git_timeout() -> float:
"""Resolve the per-git-command ceiling, ignoring an unusable override.
``inf`` is rejected as well as zero and negatives: it is positive and
parses cleanly, so accepting it would silently restore the unbounded git
call this guard exists to remove.
"""
raw = os.environ.get("PRAISONAI_KANBAN_GIT_TIMEOUT")
if not raw:
return _DEFAULT_GIT_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return _DEFAULT_GIT_TIMEOUT
if not math.isfinite(value) or value <= 0:
return _DEFAULT_GIT_TIMEOUT
return value
# Global dispatcher state
_dispatcher_running = False
_dispatcher_task = None
@@ -415,12 +443,22 @@ class KanbanDispatcher:
A thin subprocess wrapper so worktree isolation stays self-contained in
the wrapper without pulling in a Tier-2 package dependency.
Bounded via :func:`run_guarded`: the dispatcher calls this from inside
the gateway's event loop, so a git command blocked on a credential
prompt or an ``index.lock`` would otherwise stall every WebSocket
client indefinitely. On timeout the result carries returncode 124 and
a diagnosis in stderr, which the existing ``returncode == 0`` checks
already treat as failure.
The ceiling is deliberately generous — worktree creation and merges on
a large repository are legitimately slow, and the goal is to bound a
hang, not to fail slow-but-healthy work.
"""
return subprocess.run(
return run_guarded(
["git", *args],
capture_output=True,
text=True,
cwd=cwd or self._repo_dir(),
timeout=_git_timeout(),
)
def _worktree_root(self) -> str:
@@ -111,7 +111,8 @@ def _restrict_windows_acl(secret_path: str) -> bool:
"""
try:
import getpass
import subprocess
from .._guarded_subprocess import run_guarded
user = os.environ.get("USERNAME") or getpass.getuser()
# Resolve icacls from the trusted system directory rather than PATH
@@ -123,7 +124,9 @@ def _restrict_windows_acl(secret_path: str) -> bool:
)
# Quote the username so principals containing spaces (e.g. "John Doe")
# are parsed as a single principal by icacls.
result = subprocess.run(
# Bounded: this runs while hardening the pairing secret on the gateway
# startup path, so a stalled icacls must not block it forever.
result = run_guarded(
[
icacls,
secret_path,
@@ -131,7 +134,7 @@ def _restrict_windows_acl(secret_path: str) -> bool:
"/grant:r",
f'"{user}":F',
],
capture_output=True,
timeout=15,
check=False,
)
return result.returncode == 0
@@ -879,7 +879,8 @@ def _read_env_file_tokens(path: str) -> Dict[str, str]:
def _scan_launch_agent(label: str) -> DuplicateService:
"""Inspect a macOS LaunchAgent plist by label."""
import plistlib
import subprocess
from .._guarded_subprocess import run_guarded
plist_path = os.path.expanduser(f"~/Library/LaunchAgents/{label}.plist")
service = DuplicateService(label=label, plist_path=plist_path)
@@ -899,11 +900,9 @@ def _scan_launch_agent(label: str) -> DuplicateService:
pass
try:
result = subprocess.run(
["launchctl", "list", label],
capture_output=True,
text=True,
)
# Bounded: preflight runs on the gateway's startup path, and a
# launchctl that stalls would hold up service discovery indefinitely.
result = run_guarded(["launchctl", "list", label], timeout=15)
service.running = result.returncode == 0
if service.running and result.stdout:
parts = result.stdout.strip().split("\t")
@@ -1,4 +1,5 @@
"""SQLite kanban store implementation."""
import logging
import sqlite3
import json
import uuid
@@ -13,6 +14,8 @@ from .models import (
)
from .paths import get_kanban_db_path
_logger = logging.getLogger(__name__)
# Board-wide default for the per-task retry/circuit-breaker.
DEFAULT_MAX_RETRIES = 3
@@ -223,11 +226,29 @@ class SQLiteKanbanStore:
if not path or not isinstance(path, str):
return False
try:
import subprocess
result = subprocess.run(
from .._guarded_subprocess import TIMEOUT_RETURNCODE, run_guarded
# Bounded: this sits on the create_task path, which the gateway
# dispatcher calls from its event loop. A git command that stalls
# on a network-mounted or lock-contended path would otherwise
# block every card creation.
result = run_guarded(
["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True,
timeout=15,
)
if result.returncode == TIMEOUT_RETURNCODE:
# A timeout is "unknown", not "not a repository". We still
# answer False so a slow path can never fail the create, but
# say so: the caller downgrades the task to the shared
# workspace, and silently losing worktree isolation is the
# kind of thing an operator needs to see.
_logger.warning(
"Timed out probing %s for a git worktree; treating it as "
"not a repository, so this task will run in the shared "
"workspace rather than an isolated worktree. %s",
path,
result.stderr.strip(),
)
return result.returncode == 0 and result.stdout.strip() == "true"
except Exception:
return False
@@ -0,0 +1,331 @@
"""Tests for the bounded subprocess wrapper.
The behaviour that matters: a command that would block forever returns, the
caller can still branch on returncode, and the stderr it gets back says *why*
the command stalled.
"""
import os
import subprocess
import sys
import pytest
from praisonai_bot._guarded_subprocess import (
DEFAULT_TIMEOUT,
TIMEOUT_RETURNCODE,
diagnose,
run_guarded,
)
class TestHappyPath:
"""A bounded call must stay a faithful subprocess.run replacement."""
def test_returns_completed_process(self):
result = run_guarded([sys.executable, "-c", "print('ok')"])
assert result.returncode == 0
assert result.stdout.strip() == "ok"
def test_propagates_nonzero_exit(self):
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(3)"])
assert result.returncode == 3
def test_honours_cwd(self, tmp_path):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.getcwd())"],
cwd=str(tmp_path),
)
assert os.path.realpath(result.stdout.strip()) == os.path.realpath(str(tmp_path))
class TestTimeout:
"""A stalled command returns a value; it never raises and never blocks."""
def test_hanging_command_returns_instead_of_blocking(self):
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.5,
)
assert result.returncode == TIMEOUT_RETURNCODE
def test_timeout_does_not_raise(self):
# The whole point: existing `returncode == 0` call sites keep working
# without growing an exception handler.
run_guarded([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3)
def test_stderr_names_the_command_and_the_limit(self):
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.4,
)
assert "killed after 0.4s" in result.stderr
assert "time.sleep(30)" in result.stderr
def test_reading_stdin_is_not_a_hang(self):
# stdin is /dev/null, so a command that reads it gets EOF immediately
# rather than waiting for the timeout to save it.
result = run_guarded(
[sys.executable, "-c", "import sys; sys.stdin.read(); print('done')"],
timeout=5,
)
assert result.returncode == 0
assert "done" in result.stdout
class TestDiagnosis:
"""Stall output is turned into guidance a caller can act on."""
def test_credential_prompt(self):
assert "credential prompt" in diagnose("Username for 'https://github.com':")
def test_git_lock(self):
msg = diagnose("fatal: Unable to create '/repo/.git/index.lock': File exists")
assert "lock contention" in msg
def test_confirmation_prompt(self):
assert "confirmation prompt" in diagnose("Overwrite existing file? [y/N]")
def test_host_key_prompt(self):
assert "host-key" in diagnose("The authenticity of host ... key fingerprint is")
def test_unknown_output_gets_generic_guidance(self):
msg = diagnose("compiling module 41 of 900")
assert "No interactive prompt was detected" in msg
def test_empty_output_is_safe(self):
# A process blocked on stdin often emits nothing at all.
assert diagnose("") == diagnose(None)
def test_diagnosis_is_attached_on_timeout(self):
script = (
"import sys, time; "
"sys.stderr.write(\"Username for 'https://github.com':\"); "
"sys.stderr.flush(); time.sleep(30)"
)
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
assert result.returncode == TIMEOUT_RETURNCODE
assert "credential prompt" in result.stderr
class TestEnvironment:
"""Prompt suppression is layered under the caller, never over it."""
def test_suppresses_git_prompting_by_default(self):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
)
assert result.stdout.strip() == "0"
def test_caller_env_wins(self):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"],
env={"GIT_TERMINAL_PROMPT": "1", "PATH": os.environ.get("PATH", "")},
)
assert result.stdout.strip() == "1"
def test_interactive_mode_adds_no_suppression(self, monkeypatch):
# Clear the ambient value first -- CI images commonly export
# GIT_TERMINAL_PROMPT=0 already, which would mask the behaviour under
# test. Interactive mode must not *introduce* the variable itself.
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
result = run_guarded(
[
sys.executable,
"-c",
"import os; print(os.environ.get('GIT_TERMINAL_PROMPT', 'unset'))",
],
interactive=True,
)
assert result.stdout.strip() == "unset"
def test_default_mode_suppresses_even_when_ambient_is_unset(self, monkeypatch):
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
)
assert result.stdout.strip() == "0"
class TestTimeoutResolution:
"""Explicit argument beats env var beats default; junk never wins."""
def test_env_override(self, monkeypatch):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "0.3")
result = run_guarded([sys.executable, "-c", "import time; time.sleep(30)"])
assert result.returncode == TIMEOUT_RETURNCODE
@pytest.mark.parametrize("bad", ["", "abc", "0", "-5"])
def test_unusable_override_falls_back_to_default(self, monkeypatch, bad):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
# Must not raise, and must not adopt a non-positive timeout that would
# kill every command instantly.
result = run_guarded([sys.executable, "-c", "print('fine')"])
assert result.returncode == 0
assert DEFAULT_TIMEOUT > 0
def test_explicit_timeout_beats_env(self, monkeypatch):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "30")
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3
)
assert result.returncode == TIMEOUT_RETURNCODE
class TestCompatibility:
"""Call sites read .returncode/.stdout/.stderr unconditionally."""
def test_result_is_a_real_completed_process(self):
result = run_guarded([sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.3)
assert isinstance(result, subprocess.CompletedProcess)
assert result.args == [sys.executable, "-c", "import time; time.sleep(5)"]
# Call sites read .stdout/.stderr unconditionally; both must be str.
assert isinstance(result.stdout, str)
assert isinstance(result.stderr, str)
class TestKanbanGitTimeout:
"""The dispatcher's git ceiling must bound hangs without failing slow work."""
def test_default_is_generous(self):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
# Worktree add / merge on a large repo can legitimately run for
# minutes; the bound exists to stop an indefinite hang, not slow work.
assert _git_timeout() >= 300.0
def test_env_override(self, monkeypatch):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", "42")
assert _git_timeout() == 42.0
@pytest.mark.parametrize("bad", ["", "abc", "0", "-1"])
def test_unusable_override_falls_back(self, monkeypatch, bad):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
assert _git_timeout() >= 300.0
def test_run_git_is_bounded(self, monkeypatch):
"""_run_git must route through run_guarded, not raw subprocess.run."""
from praisonai_bot.gateway import kanban_dispatcher as kd
seen = {}
def fake_run_guarded(cmd, **kwargs):
seen["cmd"] = cmd
seen["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr(kd, "run_guarded", fake_run_guarded)
dispatcher = kd.KanbanDispatcher.__new__(kd.KanbanDispatcher)
dispatcher._run_git("status")
assert seen["cmd"][0] == "git"
assert seen["timeout"] is not None and seen["timeout"] > 0
class TestDescendantTermination:
"""A grandchild must not outlive the kill, nor hold the caller open.
subprocess.run's own timeout path kills only the direct child: on POSIX it
then wait()s (orphaning the grandchild), and on Windows it communicate()s
with no timeout, which blocks until the grandchild releases the inherited
pipe. Both are why run_guarded kills the whole process group.
"""
def test_returns_promptly_despite_a_surviving_descendant(self):
import time
script = (
"import subprocess, sys, time; "
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); "
"time.sleep(60)"
)
started = time.monotonic()
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
elapsed = time.monotonic() - started
assert result.returncode == TIMEOUT_RETURNCODE
# Generous bound: the point is that it does not wait out the 60s
# sleeps, on any platform.
assert elapsed < 20, f"took {elapsed:.1f}s; descendant blocked the reap"
def test_grandchild_is_actually_killed(self, tmp_path):
marker = tmp_path / "grandchild-survived.txt"
grandchild = (
f"import time; time.sleep(4); "
f"open({str(marker)!r}, 'w').write('alive')"
)
script = (
"import subprocess, sys, time; "
f"subprocess.Popen([sys.executable, '-c', {grandchild!r}]); "
"time.sleep(60)"
)
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
assert result.returncode == TIMEOUT_RETURNCODE
# Outlive the grandchild's sleep. If the group kill worked it never
# gets to write; if only the direct child died, the marker appears.
import time
time.sleep(6)
assert not marker.exists(), "grandchild survived the group kill"
class TestCheck:
"""check= matches subprocess.run, except a timeout still returns."""
def test_check_raises_on_nonzero(self):
with pytest.raises(subprocess.CalledProcessError):
run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"], check=True)
def test_check_is_off_by_default(self):
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"])
assert result.returncode == 2
def test_timeout_returns_even_with_check(self):
# A timeout is reported as a value, never an exception, so callers
# branching on returncode keep working.
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.4,
check=True,
)
assert result.returncode == TIMEOUT_RETURNCODE
class TestNonFiniteTimeoutOverrides:
"""An override that parses as positive can still be unbounded.
float("inf") passes a bare `value > 0` check, which would hand back an
infinite timeout and silently reinstate the hang this module prevents.
"""
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
def test_subprocess_timeout_rejects_non_finite(self, monkeypatch, bad):
import math
from praisonai_bot._guarded_subprocess import DEFAULT_TIMEOUT, _default_timeout
# Assert on the resolver, not on run_guarded with an explicit
# timeout= -- an explicit argument bypasses the env var entirely, so
# that spelling would pass even with the bug present.
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
resolved = _default_timeout()
assert math.isfinite(resolved)
assert resolved == DEFAULT_TIMEOUT
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
def test_git_timeout_rejects_non_finite(self, monkeypatch, bad):
import math
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
resolved = _git_timeout()
assert math.isfinite(resolved)
assert resolved >= 300.0
def test_default_resolver_stays_finite_without_override(self, monkeypatch):
import math
from praisonai_bot._guarded_subprocess import _default_timeout
monkeypatch.delenv("PRAISONAI_SUBPROCESS_TIMEOUT", raising=False)
assert math.isfinite(_default_timeout())
@@ -791,7 +791,7 @@ class AsyncTUI:
)
self._registry = create_default_registry(
self._BUILTIN_COMMANDS, include_custom=True
self._BUILTIN_COMMANDS, include_custom=True, include_skills=True
)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Command registry unavailable: %s", exc)
@@ -1883,10 +1883,20 @@ Example: /handoff code "refactor the auth module" """
from prompt_toolkit.history import FileHistory
from prompt_toolkit.completion import Completer, Completion
# Create completer for commands and files. Derive the list from the
# single source of truth (_BUILTIN_COMMANDS) so registration and
# autocomplete never drift (e.g. code-review/security-review).
# Create completer for commands and files. Start from the built-ins
# (single source of truth so registration and autocomplete never drift,
# e.g. code-review/security-review) and extend with discovered registry
# commands — custom ``.praisonai/commands/*.md`` and installed skills —
# so typing ``/`` offers them too. Degrades to built-ins on any failure.
commands = list(self._BUILTIN_COMMANDS.keys())
registry = self._get_registry()
if registry is not None:
try:
for c in registry.list_commands():
if c.name not in commands:
commands.append(c.name)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Skipping registry completions: %s", exc)
workspace_files = self._workspace_files
class PraisonCompleter(Completer):
@@ -148,13 +148,56 @@ class CustomCommandSource:
return commands
def _make_skill_handler(skill: Any) -> Callable[[str], Optional[str]]:
"""Build a handler that turns a discovered skill into a runnable prompt.
The interactive surfaces dispatch any registry command by calling its
``handler`` and feeding the returned text to the agent. A skill therefore
needs a handler that renders its ``SKILL.md`` body (plus the user's ``args``)
into a prompt, matching how :class:`CustomCommandSource` renders custom
commands. Without this, ``/my-skill`` would appear in the menu but fall
through to "Unknown command" because both dispatchers require a handler.
"""
def handler(args: str) -> Optional[str]:
name = str(getattr(skill, "name", "") or "")
description = getattr(skill, "description", "") or ""
body = ""
path = getattr(skill, "path", None)
if path is not None:
try:
from praisonaiagents.skills.parser import (
find_skill_md,
parse_frontmatter,
)
skill_md = find_skill_md(path)
if skill_md is not None:
_, body = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover - defensive
logger.debug("Skill body unavailable for %s: %s", name, exc)
sections = [f"Use the '{name}' skill."]
if description:
sections.append(description)
if body:
sections.append(body)
if args:
sections.append(args)
return "\n\n".join(section for section in sections if section)
return handler
class SkillCommandSource:
"""Optional source exposing discovered skills as invocable commands.
Skills live in the core SDK (``praisonaiagents.skills``); this source only
*consumes* them. It fails silently (returns ``[]``) when the skills module
or any project skills are unavailable, so it never adds import cost or
breaks surfaces that do not use skills.
breaks surfaces that do not use skills. Each skill carries a handler that
renders its ``SKILL.md`` body into a prompt so ``/name`` invocation works in
every interactive surface, not just the completion menu.
"""
def discover(self) -> List[Command]:
@@ -180,6 +223,7 @@ class SkillCommandSource:
description=getattr(skill, "description", "") or "Skill",
kind=CommandKind.SKILL,
source="skill",
handler=_make_skill_handler(skill),
)
)
return commands
@@ -271,7 +271,18 @@ class InteractiveREPL:
from .command_registry import create_default_registry
self._registry = create_default_registry(
DEFAULT_COMMANDS, include_custom=True
DEFAULT_COMMANDS, include_custom=True, include_skills=True
)
# Surface custom/skill/mcp commands in the ``/`` completion menu
# too — the built-ins are registered at init, but discovered
# commands (e.g. installed skills) must be added here so typing
# ``/`` offers them alongside the built-ins.
self.io.add_commands(
{
c.name: c.description
for c in self._registry.list_commands()
if c.kind.value != "builtin"
}
)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Command registry unavailable: %s", exc)
@@ -627,7 +638,12 @@ class InteractiveREPL:
tools_count=tools_count,
session_id=self._session_id
)
# Warm the command registry so discovered commands (custom + installed
# skills) populate the ``/`` completion menu before the first slash is
# typed, not only after the first custom/skill invocation.
self._get_registry()
while self._running:
try:
# Get input (SYNC - this is the key!)
@@ -0,0 +1,79 @@
"""Skills must surface in the interactive ``/`` menu (issue #4692).
The REPL and TUI build their command registry the same way; both previously
took ``include_skills=False``, so installed skills never appeared when a user
typed ``/``. These tests build the registry the way the REPL does, install one
skill into a temporary ``.praisonai/skills/``, and assert the skill both shows
up in the completion list and resolves to a dispatchable command.
"""
import os
import pytest
from praisonai_code.cli.interactive.command_registry import (
CommandKind,
create_default_registry,
)
def _install_skill(root, name: str, description: str) -> None:
skill_dir = root / ".praisonai" / "skills" / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\nBody.\n"
)
@pytest.fixture
def in_project(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
return tmp_path
def test_skill_appears_in_completions_and_dispatches(in_project):
_install_skill(in_project, "my-skill", "Does a useful thing")
registry = create_default_registry(
{"help": "Show help"}, include_custom=True, include_skills=True
)
assert "/my-skill" in registry.completions("/")
command = registry.get("my-skill")
assert command is not None
assert command.kind is CommandKind.SKILL
def test_skill_command_dispatches_via_handler(in_project):
"""A menu entry is useless if it cannot run — the handler must yield a prompt.
Both interactive dispatchers ignore commands whose ``handler`` is ``None``
(they fall through to "Unknown command"). This guards the regression where
skills were visible in ``/`` but silently un-runnable.
"""
_install_skill(in_project, "my-skill", "Does a useful thing")
registry = create_default_registry(
{"help": "Show help"}, include_custom=True, include_skills=True
)
command = registry.get("my-skill")
assert command is not None
assert command.handler is not None
prompt = command.handler("extra context")
assert prompt
assert "my-skill" in prompt
assert "Does a useful thing" in prompt
assert "Body." in prompt # SKILL.md body is rendered into the prompt
assert "extra context" in prompt # user args are appended
def test_skills_omitted_by_default(in_project):
_install_skill(in_project, "my-skill", "Does a useful thing")
registry = create_default_registry({"help": "Show help"}, include_custom=True)
assert "/my-skill" not in registry.completions("/")
assert registry.get("my-skill") is None
@@ -0,0 +1,111 @@
"""Guard against dangling lazy-command module paths (issue #4693).
``praisonai_code.cli.app._LAZY_COMMANDS`` is the authoritative registry that
maps each CLI command to the module providing its Typer sub-app. Wrapper-,
bot-, train-, browser-, mcp- and deploy-resident commands keep placeholder
``.commands.*`` paths (they are re-routed to their external package at
invocation time), so only *non-resident* entries are expected to resolve
against ``praisonai_code.cli``.
This test imports every non-resident lazy-command module so a genuinely
dangling path fails in CI rather than at first use.
A second parametrised test mirrors ``get_command()``'s resident dispatch: when
the owning package (``praisonai``, ``praisonai_bot``, ``praisonai_train``,
``praisonai_browser``, ``praisonai_mcp`` or ``praisonai_deploy``) is installed —
as in the full monorepo CI — it imports the absolute ``…​.cli.commands.<name>``
target the router re-routes to and asserts the attribute exists. This covers the
``schedule`` re-route (praisonai-code → ``praisonai.cli.commands.schedule`` after
``66dcb788a``) that the non-resident test intentionally excludes. It skips
gracefully in a standalone ``praisonai-code`` install where those packages are
absent.
"""
import importlib
import pytest
from praisonai_code.cli import app as cli_app
def _non_resident_lazy_commands():
resident = (
cli_app._WRAPPER_RESIDENT_COMMANDS
| cli_app._BOT_RESIDENT_COMMANDS
| cli_app._TRAIN_RESIDENT_COMMANDS
| cli_app._BROWSER_RESIDENT_COMMANDS
| cli_app._MCP_RESIDENT_COMMANDS
| cli_app._DEPLOY_RESIDENT_COMMANDS
)
for name, (module_path, attr_name, _desc) in cli_app._LAZY_COMMANDS.items():
if name in resident:
continue
yield name, module_path, attr_name
@pytest.mark.parametrize(
"name,module_path,attr_name",
list(_non_resident_lazy_commands()),
ids=lambda v: v if isinstance(v, str) else "",
)
def test_non_resident_lazy_command_module_resolves(name, module_path, attr_name):
module = importlib.import_module(module_path, cli_app.__package__)
assert hasattr(module, attr_name), (
f"lazy command {name!r} maps to {module_path}:{attr_name} "
f"but attribute {attr_name!r} is missing"
)
def test_special_command_modules_resolve():
for name, (module_path, attr_name, _desc) in cli_app._SPECIAL_COMMANDS.items():
module = importlib.import_module(module_path, cli_app.__package__)
assert hasattr(module, attr_name), (
f"special command {name!r} maps to {module_path}:{attr_name} "
f"but attribute {attr_name!r} is missing"
)
def _resident_lazy_commands():
resident_groups = (
(cli_app._WRAPPER_RESIDENT_COMMANDS, "praisonai", cli_app.wrapper_available),
(cli_app._BOT_RESIDENT_COMMANDS, "praisonai_bot", cli_app.bot_package_available),
(cli_app._TRAIN_RESIDENT_COMMANDS, "praisonai_train", cli_app.train_package_available),
(cli_app._BROWSER_RESIDENT_COMMANDS, "praisonai_browser", cli_app.browser_package_available),
(cli_app._MCP_RESIDENT_COMMANDS, "praisonai_mcp", cli_app.mcp_package_available),
(cli_app._DEPLOY_RESIDENT_COMMANDS, "praisonai_deploy", cli_app.deploy_package_available),
)
deploy_names = cli_app._DEPLOY_RESIDENT_COMMANDS
seen = set()
for names, root, available in resident_groups:
for name in names:
if name in seen:
continue
if name in cli_app._LAZY_COMMANDS:
# Wrapper/bot/train/browser/mcp residents are dispatched through
# ``_LAZY_COMMANDS`` using its declared attribute name.
attr_name = cli_app._LAZY_COMMANDS[name][1]
elif name in deploy_names:
# Deploy commands are dispatched separately with a hardcoded
# ``app`` attribute and are absent from ``_LAZY_COMMANDS``.
attr_name = "app"
else:
# ``app``/``standardise`` are resident but handled outside the
# generic ``_LAZY_COMMANDS`` dispatch path, so skip them here.
continue
seen.add(name)
yield name, f"{root}.cli.commands.{name}", attr_name, available
@pytest.mark.parametrize(
"name,module_path,attr_name,available",
list(_resident_lazy_commands()),
ids=lambda v: v if isinstance(v, str) else "",
)
def test_resident_lazy_command_module_resolves(name, module_path, attr_name, available):
if not available():
pytest.skip(f"owning package for resident command {name!r} not installed")
module = importlib.import_module(module_path)
assert hasattr(module, attr_name), (
f"resident command {name!r} re-routes to {module_path}:{attr_name} "
f"but attribute {attr_name!r} is missing"
)
+347
View File
@@ -0,0 +1,347 @@
"""A headless driver for the PraisonAI desktop engine.
The engine (engine/server.py) is an HTTP server the Tauri shell spawns and then
talks to over loopback. There has never been a supported way to reach it without
the window, so reproducing a desktop bug meant installing the app, opening it,
and typing -- impossible on a headless machine, over SSH, or in CI.
This is that supported way. It speaks to the same routes the app does and adds
no engine code: it starts the engine, reads its announced port, and drives it.
praisonai-desktop engine start [--home DIR] provision-free: run and print the port
praisonai-desktop engine health versions, data dir, port (from the lockfile)
praisonai-desktop chat "prompt" [--no-tools] one turn, streamed to stdout
praisonai-desktop doctor what is installed vs what the app requires
`doctor` is the one that answers a version report immediately: it prints the
installed praisonaiagents against the floor the app actually provisions, so a
"tool calling returns nothing" report resolves to "1.7.1 installed, >=1.7.2
required" in one command rather than a screen recording.
Stdlib only, exactly like the engine it drives -- so it runs in the same venv
the app builds and adds nothing to it.
"""
import argparse
import json
import os
import pathlib
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
HERE = pathlib.Path(__file__).resolve().parent
ENGINE = HERE / "engine"
# Reuse the engine's own logic rather than reimplementing the data dir, the
# lockfile format or the version lookup -- a second copy is a second thing to
# drift out of agreement with the Rust shell.
sys.path.insert(0, str(ENGINE))
import server # noqa: E402
def _required_floor():
"""The praisonaiagents floor the app provisions, read from provision.rs.
The Rust shell is the source of truth for what the engine needs
(ENGINE_PACKAGES in src-tauri/src/provision.rs). Hardcoding the version
here would be a second place to update, and the one most likely to be
forgotten -- so doctor reads the real floor and cannot disagree with what
the app actually installs.
"""
provision = HERE / "src-tauri" / "src" / "provision.rs"
try:
text = provision.read_text(encoding="utf-8")
except OSError:
return None
match = re.search(r'praisonaiagents\s*>=\s*([0-9][0-9A-Za-z.\-]*)', text)
return match.group(1) if match else None
def _lockfile_fields(home=None):
"""The engine lockfile parsed into a dict, or None if there is no lock.
The same file, in the same format, that src-tauri/src/lockfile.rs parses --
this is how the running engine is found without matching a process name
that would also match the finder itself.
"""
data_dir = pathlib.Path(home) if home else server.default_data_dir()
lock = data_dir / "engine.lock"
try:
text = lock.read_text(encoding="utf-8")
except OSError:
return None
fields = {}
for line in text.splitlines():
key, sep, value = line.partition("=")
if sep:
fields[key.strip()] = value.strip()
return fields or None
def _http_json(url, timeout=4.0):
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _live_port(home=None):
"""The port of a healthy engine, verified via /health, or None.
A lockfile alone is not proof: a crashed engine leaves one behind, and a
recycled pid can look live. The /health probe confirms the port really is
our engine before anything is sent to it -- and it must be *our* engine, so
the protocol version is checked too, not just `ok`. A recycled port that
another loopback service happens to answer with `{"ok": true}` would
otherwise be handed the user's prompt; the engine stamps every /health with
server.PROTOCOL_VERSION, which an unrelated service will not.
"""
fields = _lockfile_fields(home)
if not fields or not fields.get("port"):
return None
port = fields["port"]
try:
health = _http_json(f"http://127.0.0.1:{port}/health", timeout=1.5)
except (urllib.error.URLError, OSError, ValueError):
return None
if health.get("ok") is not True:
return None
if health.get("version") != server.PROTOCOL_VERSION:
return None
return port
def cmd_engine_start(args):
"""Run the engine in the foreground and print its port once it is listening.
This does not provision a venv -- it runs the engine with the current
interpreter, which is what makes it usable from a checkout or an existing
app environment. The port is printed to stdout the moment the socket is
bound (the engine announces it), so a caller can read one line and connect.
"""
env = dict(os.environ)
if args.home:
env["PRAISONAI_DESKTOP_HOME"] = str(pathlib.Path(args.home).expanduser())
proc = subprocess.Popen(
[sys.executable, str(ENGINE / "server.py")],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, text=True,
)
try:
for line in proc.stdout:
sys.stdout.write(line)
sys.stdout.flush()
return proc.wait()
except KeyboardInterrupt:
proc.terminate()
return proc.wait()
def cmd_engine_health(args):
"""Print the running engine's health, or say plainly that none is running."""
port = _live_port(args.home)
if port is None:
print("No running engine found (no live lockfile under the data dir).")
print("Start one with: praisonai-desktop engine start")
return 1
health = _http_json(f"http://127.0.0.1:{port}/health")
fields = _lockfile_fields(args.home) or {}
print(f"port {port}")
print(f"pid {fields.get('pid', '?')}")
print(f"protocol {health.get('version', '?')}")
print(f"agents_version {health.get('agents_version', '?')}")
print(f"data_dir {health.get('data_dir', '?')}")
return 0
def cmd_chat(args):
"""Drive one turn against a running engine and stream the answer to stdout.
This is the reproduction primitive the issue asks for: a tool-calling
failure that took a GUI and several screenshots to show becomes one command
whose output is the answer. It parses the engine's SSE stream so a turn that
ran tools but returned no text -- the exact 1.7.1 failure -- is reported as
such rather than as a blank success.
"""
port = _live_port(args.home)
if port is None:
print("No running engine found. Start one first with: "
"praisonai-desktop engine start", file=sys.stderr)
return 1
# session, not just chat_id: the engine caches one agent per `session`
# (default "default") and only loads history per chat_id, so without this
# every --chat-id would share one agent and inherit the others' history.
body = json.dumps({
"prompt": args.prompt,
"session": args.chat_id,
"chat_id": args.chat_id,
"run_id": args.chat_id,
"tools": not args.no_tools,
}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/chat", data=body,
headers={"content-type": "application/json"})
text_chars = 0
tools_ran = 0
error = None
try:
with urllib.request.urlopen(req, timeout=args.timeout) as resp:
for event, payload in _iter_sse(resp):
if event == "delta":
chunk = payload.get("text", "")
text_chars += len(chunk)
sys.stdout.write(chunk)
sys.stdout.flush()
elif event == "tool_result":
tools_ran += 1
elif event == "approval_request":
# A tool-enabled turn can hit the approval gate (the engine
# defaults approval_mode to "ask"), which blocks the run for
# up to its 300s timeout waiting on /approve. There is no
# human on a headless turn, so answer it here rather than
# hang: --approve to allow, otherwise deny. A denied tool is
# recoverable; a frozen turn is not.
_answer_approval(port, payload,
"allow" if args.approve else "deny")
elif event == "error":
error = payload.get("message", "unknown error")
except (urllib.error.URLError, OSError) as exc:
print(f"\nRequest failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if text_chars:
sys.stdout.write("\n")
if error:
print(f"error: {error}", file=sys.stderr)
return 1
if not text_chars:
# The 1.7.1 signature, named rather than shown as an empty answer.
note = (f"{tools_ran} tool call(s) ran, but the model returned no text."
if tools_ran else "the engine produced no output.")
print(f"error: {note}", file=sys.stderr)
return 1
return 0
def _iter_sse(resp):
"""Yield (event, data-dict) pairs from a text/event-stream response.
Frames are blank-line separated; each carries an `event:` and a `data:`
line. A data line that is not JSON is skipped rather than crashing the
stream, since a partial frame is recoverable and a dropped connection is
not.
"""
event = "message"
for raw in resp:
line = raw.decode("utf-8", "replace").rstrip("\n")
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
try:
yield event, json.loads(line[len("data:"):].strip())
except ValueError:
pass
elif line == "":
event = "message"
def _answer_approval(port, payload, choice):
"""Answer an approval_request so a headless turn does not block on the gate.
Posts the decision to /approve/{id}; failure is swallowed because the turn
will fall back to the engine's own approval timeout, and a broken answer
must not itself crash the stream we are still reading.
"""
aid = payload.get("approval_id")
if not aid:
return
body = json.dumps({"choice": choice}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/approve/{aid}", data=body,
headers={"content-type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=4.0):
pass
except (urllib.error.URLError, OSError):
pass
def cmd_doctor(args):
"""Report what is installed against what the app requires.
The command that would have answered the original report on its own: it
prints the installed praisonaiagents and the floor the app provisions, and
exits non-zero when the installed version is below that floor -- so it works
as a check in a script, not only as something to read.
"""
installed = server._installed_version()
floor = _required_floor()
print(f"python {sys.version.split()[0]} ({sys.executable})")
print(f"praisonaiagents {installed}")
if floor:
print(f"required >={floor}")
problems = []
if installed == "unknown":
problems.append("praisonaiagents is not installed in this environment.")
elif floor and server._vtuple(installed) < server._vtuple(floor):
problems.append(
f"praisonaiagents {installed} is below the required floor >={floor}. "
"A tool-using turn can end without the model being asked for its "
"answer; upgrade with: pip install -U 'praisonaiagents'")
port = _live_port(args.home)
print(f"engine {'running on port ' + port if port else 'not running'}")
if problems:
print()
for line in problems:
print(f"PROBLEM: {line}")
return 1
print("\nAll checks passed.")
return 0
def build_parser():
parser = argparse.ArgumentParser(
prog="praisonai-desktop",
description="Drive the PraisonAI desktop engine without the GUI.")
sub = parser.add_subparsers(dest="command", required=True)
engine = sub.add_parser("engine", help="start or inspect the engine")
engine_sub = engine.add_subparsers(dest="engine_command", required=True)
start = engine_sub.add_parser("start", help="run the engine and print its port")
start.add_argument("--home", help="data directory (sets PRAISONAI_DESKTOP_HOME)")
start.set_defaults(func=cmd_engine_start)
health = engine_sub.add_parser("health", help="show a running engine's health")
health.add_argument("--home", help="data directory to look for the lockfile in")
health.set_defaults(func=cmd_engine_health)
chat = sub.add_parser("chat", help="run one turn against a running engine")
chat.add_argument("prompt", help="the message to send")
chat.add_argument("--no-tools", action="store_true", help="disable built-in tools")
chat.add_argument("--approve", action="store_true",
help="auto-allow tool approval prompts (default: deny)")
chat.add_argument("--chat-id", default="cli", help="conversation id to use")
chat.add_argument("--home", help="data directory to look for the lockfile in")
chat.add_argument("--timeout", type=float, default=120.0, help="seconds to wait")
chat.set_defaults(func=cmd_chat)
doctor = sub.add_parser("doctor", help="what is installed vs required")
doctor.add_argument("--home", help="data directory to look for the lockfile in")
doctor.set_defaults(func=cmd_doctor)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+100 -7
View File
@@ -664,6 +664,39 @@ def save_mcp(servers: list) -> None:
tmp = MCP_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps({"servers": servers}, indent=1))
_replace_with_retry(tmp, MCP_PATH)
PROJECTS_PATH = DATA_DIR / "projects.json"
def load_projects() -> dict:
"""Per-project settings, keyed by the project name a chat is filed under.
Only `instructions` today: a string prepended to every turn in the
project, so a chat in "Alpha" inherits Alpha's standing directions the
way Claude's Projects do. The name is the same string `/project/<id>`
writes onto a chat, so nothing else needs to change to link the two.
"""
try:
stored = json.loads(PROJECTS_PATH.read_text())
return stored if isinstance(stored, dict) else {}
except (OSError, ValueError):
return {}
def save_projects(projects: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = PROJECTS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(projects, indent=1))
_replace_with_retry(tmp, PROJECTS_PATH)
def project_instructions(name: str) -> str:
"""The standing instructions for a project, or "" when it has none."""
if not name:
return ""
entry = load_projects().get(name) or {}
return str(entry.get("instructions", "")) if isinstance(entry, dict) else ""
# Mirrors frontend/dist/settings-registry.js. The registry is the source of
# truth for the UI; this is the storage contract, and load_settings() drops
# unknown keys and defaults missing ones so a hand-edited or older file can
@@ -1372,6 +1405,25 @@ def _persist(chat_id: str, prompt: str, reply: list,
return None
def redacted(cfg: dict) -> dict:
"""A settings dict that is safe to put on the wire.
GET /settings has masked the key since the beginning. POST /settings
returned save_settings()'s merged dict, and that dict starts from
load_settings(), which reads the real secret back out of the keychain -- so
every settings write answered with the credential in cleartext, including a
write that only changed the theme.
Masking one of two exits is not masking. The secret is stored exactly as
before; only what leaves the process changes.
"""
out = dict(cfg)
for key in SECRET_KEYS:
if key in out:
out[key] = "\u2022" * 8 if out[key] else ""
return out
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@@ -1530,13 +1582,18 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/settings":
cfg = load_settings()
# The UI only needs to know whether a key is set, never its value.
cfg["api_key"] = "\u2022" * 8 if cfg.get("api_key") else ""
self._json(cfg)
self._json(redacted(cfg))
return
if self.path == "/projects":
names = sorted({(load_chat(c["id"]).get("project") or "")
for c in list_chats()} - {""})
self._json({"projects": names})
# Names come from the chats filed under them; instructions come from
# the project store. A project keeps its instructions even after its
# last chat leaves, so the two lists are unioned, not intersected.
stored = load_projects()
names = sorted(({(load_chat(c["id"]).get("project") or "")
for c in list_chats()} | set(stored)) - {""})
self._json({"projects": names,
"instructions": {n: project_instructions(n)
for n in names}})
return
if self.path == "/mcp":
self._json({"servers": load_mcp()})
@@ -1714,7 +1771,8 @@ class Handler(BaseHTTPRequestHandler):
saved["launch_at_login_result"] = result
else:
saved = save_settings(patch)
self._json(saved)
# Redacted: this is the reply that carried the key in cleartext.
self._json(redacted(saved))
return
if self.path == "/mcp":
@@ -1799,6 +1857,25 @@ class Handler(BaseHTTPRequestHandler):
self._json({"ok": False, "error": "cannot fork"}, 404)
return
if self.path == "/projects":
# Standing instructions for a project, edited from the sidebar and
# prepended to every turn of its chats. Blank clears the entry so a
# project with nothing to say leaves no stale file behind.
body = self._body()
name = str(body.get("project", ""))[:80].strip()
if not name:
self._json({"ok": False, "error": "project name required"}, 400)
return
text = str(body.get("instructions", ""))[:20_000].strip()
projects = load_projects()
if text:
projects[name] = {"instructions": text}
else:
projects.pop(name, None)
save_projects(projects)
self._json({"ok": True, "project": name, "instructions": text})
return
if self.path.startswith("/project/"):
cid = self.path.rsplit("/", 1)[-1]
name = self._body().get("project", "")
@@ -1869,6 +1946,22 @@ class Handler(BaseHTTPRequestHandler):
self.send_error(400)
return
# A chat filed under a project inherits that project's standing
# instructions. They ride in front of this turn only -- `prompt` stays
# clean, so the transcript and the auto-title show what the user typed,
# not the project preamble, and a chat that later leaves the project
# keeps a history free of it. The agent is cached per session and shared
# across chats with different projects, so this is done per turn rather
# than baked into the agent's own instructions.
turn_prompt = prompt
try:
proj = load_chat(chat_id).get("project") or ""
except Exception: # noqa: BLE001 - a missing chat simply has no project
proj = ""
proj_text = project_instructions(proj)
if proj_text:
turn_prompt = f"{proj_text}\n\n{prompt}"
self.send_response(200)
self._cors()
self.send_header("Content-Type", "text/event-stream")
@@ -1937,7 +2030,7 @@ class Handler(BaseHTTPRequestHandler):
"seconds": ev["seconds"]})
return shown
for chunk in agent.start(prompt, stream=True,
for chunk in agent.start(turn_prompt, stream=True,
**_llm_overrides(load_settings())):
# Counted here too. This call drains the queue, so discarding
# its result meant the tally further down always read zero
+211
View File
@@ -0,0 +1,211 @@
"""The headless CLI must drive the engine the app does, and only that.
These tests exercise the parts that can be checked without a live engine or a
network: the doctor's version comparison (the command that answers a version
report), the required-floor read from provision.rs (which must not drift from
the Rust source of truth), the lockfile parser, and the SSE stream reader that
turns a turn's frames back into text and tool counts.
"""
import io
import pathlib
import sys
import unittest
HERE = pathlib.Path(__file__).resolve().parent
DESKTOP = HERE.parent
sys.path.insert(0, str(DESKTOP))
import cli # noqa: E402
class Args:
"""A stand-in for the argparse namespace the command functions take."""
def __init__(self, **kw):
self.home = None
self.__dict__.update(kw)
class RequiredFloor(unittest.TestCase):
"""The floor doctor checks against comes from provision.rs, not a constant."""
def test_reads_the_provisioned_floor(self):
floor = cli._required_floor()
self.assertIsNotNone(floor, "provision.rs no longer states a praisonaiagents floor")
# A version, not a range fragment: it must be usable with _vtuple.
self.assertRegex(floor, r"^\d+\.\d+")
def test_floor_is_the_one_the_shell_provisions(self):
# The value here is not asserted verbatim -- that would just duplicate
# the constant -- but it must parse to a tuple, which is all doctor
# needs to compare installed against required.
floor = cli._required_floor()
self.assertGreaterEqual(len(cli.server._vtuple(floor)), 2)
class LockfileParsing(unittest.TestCase):
def test_absent_lock_is_none_not_an_error(self):
self.assertIsNone(cli._lockfile_fields(home="/definitely/not/a/dir"))
def test_fields_are_parsed_key_by_key(self, ):
import tempfile
with tempfile.TemporaryDirectory() as d:
(pathlib.Path(d) / "engine.lock").write_text(
"format_version=2\nport=54321\npid=999\n", encoding="utf-8")
fields = cli._lockfile_fields(home=d)
self.assertEqual(fields["port"], "54321")
self.assertEqual(fields["pid"], "999")
class SseReader(unittest.TestCase):
"""The stream reader must recover text and tool counts from raw frames."""
def _frames(self, blob):
return list(cli._iter_sse(io.BytesIO(blob.encode())))
def test_delta_frames_yield_text(self):
events = self._frames(
'event: delta\ndata: {"text": "Hi "}\n\n'
'event: delta\ndata: {"text": "there"}\n\n')
text = "".join(p["text"] for e, p in events if e == "delta")
self.assertEqual(text, "Hi there")
def test_a_non_json_data_line_is_skipped_not_fatal(self):
events = self._frames(
'event: delta\ndata: not json\n\n'
'event: delta\ndata: {"text": "ok"}\n\n')
self.assertEqual([p["text"] for e, p in events if e == "delta"], ["ok"])
class Doctor(unittest.TestCase):
"""doctor exits non-zero exactly when the installed engine is too old."""
def _run(self, installed, floor):
real_installed = cli.server._installed_version
real_floor = cli._required_floor
real_port = cli._live_port
cli.server._installed_version = lambda: installed
cli._required_floor = lambda: floor
cli._live_port = lambda home=None: None
buf = io.StringIO()
real_stdout = sys.stdout
sys.stdout = buf
try:
code = cli.cmd_doctor(Args())
finally:
sys.stdout = real_stdout
cli.server._installed_version = real_installed
cli._required_floor = real_floor
cli._live_port = real_port
return code, buf.getvalue()
def test_below_floor_is_a_problem(self):
code, out = self._run("1.7.1", "1.7.2")
self.assertEqual(code, 1)
self.assertIn("below the required floor", out)
def test_at_or_above_floor_passes(self):
code, out = self._run("1.7.3", "1.7.2")
self.assertEqual(code, 0)
self.assertIn("All checks passed", out)
def test_missing_install_is_a_problem(self):
code, out = self._run("unknown", "1.7.2")
self.assertEqual(code, 1)
self.assertIn("not installed", out)
class ParserWiring(unittest.TestCase):
"""Every subcommand parses and binds to a handler."""
def test_subcommands_bind_to_functions(self):
parser = cli.build_parser()
for argv, func in (
(["engine", "start"], cli.cmd_engine_start),
(["engine", "health"], cli.cmd_engine_health),
(["chat", "hello"], cli.cmd_chat),
(["doctor"], cli.cmd_doctor),
):
args = parser.parse_args(argv)
self.assertIs(args.func, func)
def test_chat_defaults_deny_approval(self):
args = cli.build_parser().parse_args(["chat", "hi"])
self.assertFalse(args.approve)
def test_chat_approve_flag(self):
args = cli.build_parser().parse_args(["chat", "hi", "--approve"])
self.assertTrue(args.approve)
class LivePort(unittest.TestCase):
"""Only *our* engine is adopted: ok plus the right protocol version."""
def _run(self, fields, health):
real_fields = cli._lockfile_fields
real_http = cli._http_json
cli._lockfile_fields = lambda home=None: fields
cli._http_json = lambda url, timeout=4.0: health
try:
return cli._live_port()
finally:
cli._lockfile_fields = real_fields
cli._http_json = real_http
def test_matching_protocol_is_adopted(self):
port = self._run({"port": "5000"},
{"ok": True, "version": cli.server.PROTOCOL_VERSION})
self.assertEqual(port, "5000")
def test_wrong_protocol_is_rejected(self):
# A recycled port answered by an unrelated loopback service.
port = self._run({"port": "5000"},
{"ok": True, "version": cli.server.PROTOCOL_VERSION + 99})
self.assertIsNone(port)
def test_not_ok_is_rejected(self):
port = self._run({"port": "5000"}, {"ok": False})
self.assertIsNone(port)
class ApprovalAnswer(unittest.TestCase):
"""A headless turn answers the approval gate rather than blocking on it."""
def test_posts_choice_to_approve_route(self):
sent = {}
class _FakeResp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _fake_urlopen(req, timeout=None):
sent["url"] = req.full_url
sent["body"] = req.data
return _FakeResp()
real = cli.urllib.request.urlopen
cli.urllib.request.urlopen = _fake_urlopen
try:
cli._answer_approval("5000", {"approval_id": "ap_1"}, "deny")
finally:
cli.urllib.request.urlopen = real
self.assertIn("/approve/ap_1", sent["url"])
self.assertIn(b"deny", sent["body"])
def test_missing_id_is_a_noop(self):
called = []
real = cli.urllib.request.urlopen
cli.urllib.request.urlopen = lambda *a, **k: called.append(1)
try:
cli._answer_approval("5000", {}, "deny")
finally:
cli.urllib.request.urlopen = real
self.assertEqual(called, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,157 @@
"""A recycled pid must not be mistaken for a run this engine started.
_reload() adopted any persisted run whose pid was merely alive. PIDs are
recycled, and an adopted run carries no _proc and no supervisor -- so nothing
would ever reap it. The consequences were not "one adopted-then-reaped run",
as _pid_alive's docstring supposed:
* the run stays RUNNING forever, and start() refuses to launch a new one
while a live run exists -- fine-tuning is blocked until someone finds and
deletes the state file by hand;
* stop() takes the `proc is None and run.pid` branch and calls
_terminate_pid_group(run.pid), sending SIGTERM to the process group of
whatever unrelated program now holds that pid.
src-tauri/src/adopt.rs already refused adoption on a live pid alone, comparing
`start_time` and naming the rejection `PidReused`. The Rust shell guarded the
engine; nothing guarded the training job. These tests pin the Python side to
the same rule.
Run: .venv/bin/python -m unittest discover -s engine -p 'test_*.py'
"""
import importlib.util
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import unittest
_spec = importlib.util.spec_from_file_location(
"training_pid_reuse", pathlib.Path(__file__).with_name("training.py"))
training = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(training)
class PidReuse(unittest.TestCase):
def setUp(self):
self.home = tempfile.mkdtemp(prefix="praison-pidreuse-")
self.procs = []
def tearDown(self):
for p in self.procs:
p.kill()
p.wait()
shutil.rmtree(self.home, ignore_errors=True)
def sleeper(self):
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
self.procs.append(p)
return p
def write_state(self, run_id, pid, pid_start):
"""A persisted run mid-flight, as a previous engine would have left it."""
runs = os.path.join(self.home, "runs", run_id)
os.makedirs(runs, exist_ok=True)
state = {"id": run_id, "state": training.RUNNING, "step": 3, "total": 10,
"pid": pid, "pid_start": pid_start}
trainer = training.Trainer(self.home, sys.executable)
with open(trainer._state_path(run_id), "w", encoding="utf-8") as fh:
json.dump(state, fh)
def reload(self):
return training.Trainer(self.home, sys.executable)
def test_a_recycled_pid_is_not_adopted(self):
"""The pid is alive, but it is not the process the run started."""
proc = self.sleeper()
self.write_state("r1", proc.pid, "Thu Jan 1 00:00:00 1970")
trainer = self.reload()
self.assertIsNone(
trainer.current,
"adopted an unrelated live process -- stop() would SIGTERM it")
def test_a_recycled_pid_does_not_block_new_runs(self):
"""The user-visible half: fine-tuning must not be wedged shut."""
proc = self.sleeper()
self.write_state("r1", proc.pid, "Thu Jan 1 00:00:00 1970")
trainer = self.reload()
run = next(r for r in trainer.history if r.id == "r1")
self.assertIn(run.state, training.TERMINAL,
"a run nothing can ever reap was left non-terminal")
def test_the_real_process_is_still_adopted(self):
"""The guard must not throw away genuine adoption."""
proc = self.sleeper()
self.write_state("r1", proc.pid, training._pid_start_time(proc.pid))
trainer = self.reload()
self.assertIsNotNone(trainer.current, "refused to adopt a genuinely live run")
self.assertEqual(trainer.current.id, "r1")
self.assertEqual(trainer.current.state, training.RUNNING)
def test_a_dead_pid_is_not_adopted(self):
proc = self.sleeper()
stamp = training._pid_start_time(proc.pid)
proc.kill()
proc.wait()
self.write_state("r1", proc.pid, stamp)
self.assertIsNone(self.reload().current)
def test_a_state_file_without_a_fingerprint_is_not_adopted(self):
"""Pre-upgrade state: ownership cannot be proven, so it is refused.
Adopting would risk SIGTERM to a stranger; refusing costs at most one
run shown as interrupted, across a single restart after upgrade.
"""
proc = self.sleeper()
self.write_state("r1", proc.pid, None)
self.assertIsNone(self.reload().current)
def test_persist_writes_the_fingerprint_to_disk(self):
"""The round trip through _persist, not a hand-written state file.
Every other test here writes the state file itself, so a _persist that
quietly stopped recording pid_start would leave them all green while
no real run could ever be adopted again -- adoption would fail closed
and look like "the engine restarted while this run was live" forever.
"""
trainer = training.Trainer(self.home, sys.executable)
run = training.Run("r9", os.path.join(self.home, "c.yaml"),
os.path.join(self.home, "t.log"))
run.state, run.pid, run.pid_start = training.RUNNING, 4321, "STAMP-XYZ"
os.makedirs(os.path.join(self.home, "runs", "r9"), exist_ok=True)
trainer._persist(run)
with open(trainer._state_path("r9"), encoding="utf-8") as fh:
saved = json.load(fh)
self.assertEqual(saved.get("pid_start"), "STAMP-XYZ",
"_persist dropped the fingerprint; adoption would fail closed")
def test_a_persisted_live_run_round_trips_into_adoption(self):
"""End to end: persist a real live run, reload, and adopt it."""
proc = self.sleeper()
trainer = training.Trainer(self.home, sys.executable)
run = training.Run("r8", os.path.join(self.home, "c.yaml"),
os.path.join(self.home, "t.log"))
run.state, run.pid = training.RUNNING, proc.pid
run.pid_start = training._pid_start_time(proc.pid)
os.makedirs(os.path.join(self.home, "runs", "r8"), exist_ok=True)
trainer._persist(run)
self.assertIsNotNone(self.reload().current,
"a run persisted by this code could not be adopted back")
def test_the_fingerprint_is_recorded_when_a_run_spawns(self):
"""Recorded at spawn -- reading it at adoption time would fingerprint
whoever holds the pid then, which is the thing being guarded against."""
src = pathlib.Path(__file__).with_name("training.py").read_text()
self.assertIn("run.pid_start = _pid_start_time(proc.pid)", src)
spawn = src.index("run.pid = proc.pid")
reload_at = src.index("def _reload")
self.assertLess(spawn, reload_at + len(src),
"fingerprint must be captured at spawn")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,174 @@
"""A project is standing context, not just a sidebar label.
"Move to project" regrouped the sidebar and changed nothing the model saw: a
chat filed under "Alpha" got no Alpha-specific instructions and answered
exactly like a chat filed under nothing. These tests pin the fix -- a
per-project instructions string, prepended to every turn of its chats -- and
guard the parts that must stay untouched: the transcript, the auto-title, and
a chat with no project.
The agent is stubbed. It records the prompt it was handed, because what the
model is shown is the thing under test; it never reaches a provider.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
_HOME = tempfile.mkdtemp(prefix="praison-project-test-")
os.environ["PRAISONAI_DESKTOP_HOME"] = _HOME
os.environ.setdefault("PRAISONAI_KEYCHAIN_SERVICE", "ai.praison.desktop.test")
import server # noqa: E402
class StubAgent:
"""An agent whose stream is fixed, recording the prompt it was given."""
def __init__(self, chunks=("ok",)):
self.chunks = list(chunks)
self.chat_history = []
self.seen_prompt = None
def _append_to_chat_history(self, message):
self.chat_history.append(message)
def start(self, prompt, stream=True, **kwargs):
self.seen_prompt = prompt
for chunk in self.chunks:
yield chunk
class Projects(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
cls.port = cls.server.server_address[1]
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
shutil.rmtree(_HOME, ignore_errors=True)
def setUp(self):
server._agents.clear()
server._tool_queue().clear()
server.save_projects({})
def _post(self, path, body):
req = urllib.request.Request(
f"http://127.0.0.1:{self.port}{path}",
data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read() or b"{}")
def _get(self, path):
with urllib.request.urlopen(
f"http://127.0.0.1:{self.port}{path}", timeout=30) as r:
return json.loads(r.read() or b"{}")
def _chat(self, chat_id, prompt="hello"):
# /chat is an SSE stream, not JSON -- read and discard the frames.
agent = StubAgent()
server._agents[chat_id] = agent
server._agents[chat_id + "\x00notools"] = agent
req = urllib.request.Request(
f"http://127.0.0.1:{self.port}/chat",
data=json.dumps({"prompt": prompt, "chat_id": chat_id,
"session": chat_id}).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
r.read()
return agent
# -- the reported bug ------------------------------------------------
def test_only_the_project_chat_carries_the_project_instructions(self):
# The heart of the issue: two chats, one in a project with standing
# instructions, one in none. Only the project chat's turn should carry
# them.
self._post("/projects", {"project": "Alpha",
"instructions": "Always answer in French."})
self._post("/project/plain", {"project": ""})
self._post("/project/inproj", {"project": "Alpha"})
plain = self._chat("plain")
inproj = self._chat("inproj")
self.assertNotIn("French", plain.seen_prompt or "",
"a chat in no project inherited project instructions")
self.assertIn("Always answer in French.", inproj.seen_prompt or "",
"a chat in the project did not inherit its instructions")
def test_the_instructions_lead_the_prompt(self):
self._post("/projects", {"project": "Alpha",
"instructions": "Answer in French."})
self._post("/project/c", {"project": "Alpha"})
agent = self._chat("c", prompt="what is the time")
self.assertTrue((agent.seen_prompt or "").startswith("Answer in French."),
"project instructions must precede the user's turn")
self.assertIn("what is the time", agent.seen_prompt)
# -- what must stay untouched ---------------------------------------
def test_the_transcript_records_only_what_the_user_typed(self):
# The preamble rides in front of one turn; the stored history must not
# gain it, or reopening the chat -- and its auto-title -- would show it.
self._post("/projects", {"project": "Alpha",
"instructions": "Answer in French."})
self._post("/project/keep", {"project": "Alpha"})
self._chat("keep", prompt="bonjour question")
chat = server.load_chat("keep")
users = [m["content"] for m in chat["messages"] if m["role"] == "user"]
self.assertEqual(users, ["bonjour question"],
f"the project preamble leaked into the transcript: {users}")
self.assertNotIn("French", chat.get("title", ""))
def test_a_project_without_instructions_changes_nothing(self):
self._post("/project/bare", {"project": "Beta"})
agent = self._chat("bare", prompt="hi there")
self.assertEqual(agent.seen_prompt, "hi there")
# -- the endpoint ----------------------------------------------------
def test_get_projects_reports_stored_instructions(self):
self._post("/projects", {"project": "Alpha", "instructions": "Be terse."})
data = self._get("/projects")
self.assertIn("Alpha", data["projects"])
self.assertEqual(data["instructions"]["Alpha"], "Be terse.")
def test_blank_instructions_clear_the_project(self):
self._post("/projects", {"project": "Alpha", "instructions": "x"})
self._post("/projects", {"project": "Alpha", "instructions": ""})
self.assertNotIn("Alpha", server.load_projects())
def test_a_project_keeps_instructions_without_any_chat(self):
# Instructions can be authored before the first chat is filed there.
self._post("/projects", {"project": "Empty", "instructions": "later."})
self.assertIn("Empty", self._get("/projects")["projects"])
def test_post_projects_requires_a_name(self):
req = urllib.request.Request(
f"http://127.0.0.1:{self.port}/projects",
data=json.dumps({"instructions": "x"}).encode(), method="POST",
headers={"Content-Type": "application/json"})
with self.assertRaises(urllib.error.HTTPError) as ctx:
urllib.request.urlopen(req, timeout=30)
self.assertEqual(ctx.exception.code, 400)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,116 @@
"""No settings reply may carry the API key in cleartext.
GET /settings masked the key from the start. POST /settings answered with
save_settings()'s dict, which begins at load_settings() and so holds the real
secret read back out of the keychain -- meaning every settings write replied
with the credential, including a write that only changed the theme. Combined
with `Access-Control-Allow-Origin: *`, any page the user had open could read
it.
These tests assert the EFFECT: the secret must not appear in the bytes that
leave the process. Asserting the shape of the dict would not have caught it --
the dict was correct, it was simply the wrong dict.
Run: .venv/bin/python -m unittest discover -s engine -p 'test_*.py'
"""
import importlib.util
import json
import os
import pathlib
import sys
import tempfile
import threading
import unittest
import urllib.request
from http.server import ThreadingHTTPServer
# Both isolations must be set BEFORE server.py is imported: KEYCHAIN_SERVICE and
# SETTINGS_PATH are bound at module scope. PRAISONAI_DESKTOP_HOME alone moves the
# data directory but NOT the system keyring, which is shared per user -- writing a
# test key under the default service overwrites the real one the person is using,
# and the keychain keeps no previous value to put back. This is not hypothetical:
# it happened while this very test was being written.
_HOME = tempfile.mkdtemp(prefix="praison-redaction-")
os.environ["PRAISONAI_DESKTOP_HOME"] = _HOME
os.environ["PRAISONAI_KEYCHAIN_SERVICE"] = "ai.praison.desktop.test." + str(os.getpid())
_spec = importlib.util.spec_from_file_location(
"engine_server_redaction", pathlib.Path(__file__).with_name("server.py"))
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
assert server.KEYCHAIN_SERVICE != "ai.praison.desktop", (
"refusing to run: this test would write into the real keychain service")
CANARY = "sk-ant-CANARY-do-not-leak-0123456789"
class SettingsRedaction(unittest.TestCase):
"""Drive the real Handler over a real socket; inspect the raw bytes."""
def setUp(self):
server.SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
if server.SETTINGS_PATH.exists():
server.SETTINGS_PATH.unlink()
for key in server.SECRET_KEYS:
server.keychain_set(key, "")
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.base = "http://127.0.0.1:%d" % self.httpd.server_address[1]
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
for key in server.SECRET_KEYS:
server.keychain_set(key, "")
def call(self, method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
self.base + path, data=data, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode()
def test_storing_the_key_does_not_echo_it(self):
body = self.call("POST", "/settings", {"api_key": CANARY})
self.assertNotIn(CANARY, body,
"POST /settings echoed the API key back in cleartext")
def test_unrelated_write_does_not_return_the_key(self):
"""The theme write is the damning one: it never mentions the key."""
self.call("POST", "/settings", {"api_key": CANARY})
body = self.call("POST", "/settings", {"theme": "dark"})
self.assertNotIn(CANARY, body,
"a theme-only write replied with the stored API key")
def test_get_does_not_return_the_key(self):
self.call("POST", "/settings", {"api_key": CANARY})
self.assertNotIn(CANARY, self.call("GET", "/settings"))
def test_the_key_is_still_stored_and_usable(self):
"""Redaction must change only what leaves, never what is kept."""
self.call("POST", "/settings", {"api_key": CANARY})
self.assertEqual(server.load_settings().get("api_key"), CANARY,
"redaction destroyed the stored credential")
def test_masking_is_not_mistaken_for_a_value(self):
"""An empty key must stay empty, not become eight bullets."""
self.assertEqual(server.redacted({"api_key": ""})["api_key"], "")
def test_non_secret_fields_survive(self):
out = server.redacted({"theme": "dark", "api_key": CANARY})
self.assertEqual(out["theme"], "dark")
self.assertNotIn(CANARY, json.dumps(out))
def test_every_declared_secret_is_covered(self):
"""New entries in SECRET_KEYS must be redacted without new code."""
probe = {k: "SECRET-" + k for k in server.SECRET_KEYS}
blob = json.dumps(server.redacted(probe))
for k in server.SECRET_KEYS:
self.assertNotIn("SECRET-" + k, blob,
"%s is declared secret but is not redacted" % k)
if __name__ == "__main__":
unittest.main()
+97 -6
View File
@@ -163,6 +163,10 @@ class Run:
# but the pid outlives the restart and is how stop() reaches an
# adopted run.
self.pid = None
# A pid alone is ambiguous after a restart: the OS may have handed it to
# an unrelated process. The start time pins which process the pid meant,
# exactly as src-tauri/src/adopt.rs does for the engine.
self.pid_start = None
self._lock = threading.Lock()
# -- event history --------------------------------------------------
@@ -259,7 +263,8 @@ class Trainer:
tmp = f"{path}.{os.getpid()}.tmp"
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({**run.summary(), "pid": run.pid}, fh)
json.dump({**run.summary(), "pid": run.pid,
"pid_start": run.pid_start}, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
@@ -299,8 +304,9 @@ class Trainer:
run.started = saved.get("started", run.started)
run.ended, run.error = saved.get("ended"), saved.get("error")
run.pid = saved.get("pid")
run.pid_start = saved.get("pid_start")
if run.state not in TERMINAL:
if run.pid and _pid_alive(run.pid):
if run.pid and _same_process(run.pid, run.pid_start):
run.state = RUNNING
self.current = run # stop() signals run.pid's group
else:
@@ -433,6 +439,10 @@ class Trainer:
return
run._proc = proc
run.pid = proc.pid
# Read the fingerprint now, while the child is certainly still this
# process: reading it at adoption time would fingerprint whoever holds
# the pid then, which is the thing being guarded against.
run.pid_start = _pid_start_time(proc.pid)
# Written now, before the RUNNING transition, so a crash in the next
# instruction still leaves a pid on disk for the next process to adopt
# or reap rather than a run that looks like it never spawned.
@@ -677,10 +687,13 @@ def _pid_alive(pid):
signal but CTRL_C/CTRL_BREAK to TerminateProcess, so it would kill a live
pid; Windows therefore gets an OpenProcess probe that only observes.
This does not guard against pid reuse -- a minimal version, as the audit
noted. The window is a restart landing on a recycled pid, which is narrow
on the desktop; the cost of getting it wrong is one adopted-then-reaped
run, not a lost GPU.
Liveness alone is NOT sufficient to adopt a run -- pids are recycled. Call
_same_process() for that. This function used to be the whole adoption test,
and the cost of a wrong answer was not "one adopted-then-reaped run" as
previously recorded here: an adopted run has no _proc and no supervisor, so
nothing ever reaps it. It stayed RUNNING forever, start() refused every new
run while it did, and stop() SIGTERMed the process group of whatever now
held the pid.
"""
try:
pid = int(pid)
@@ -723,6 +736,84 @@ def _pid_alive_windows(pid):
kernel32.CloseHandle(handle)
def _pid_start_time(pid):
"""A fingerprint distinguishing this process from a later reuse of its pid.
src-tauri/src/adopt.rs already refuses to adopt on a live pid alone -- it
compares `start_time`, and names the failure `PidReused`. The Rust shell
guards the engine that way; the trainer did not guard the training job,
so the same recycled pid that adopt.rs rejects was adopted here.
POSIX `lstart` resolves to the second, so this cannot separate two
processes that share a pid *and* started within the same second -- which
requires the recycled process to be spawned in the same second as the one
it replaced. The remaining ambiguity is far narrower than a bare pid.
Returns None when the answer cannot be established (no such process, a
platform that will not say, a probe that failed). None is not "matches" --
callers must treat it as "cannot prove ownership".
"""
try:
pid = int(pid)
except (TypeError, ValueError):
return None
if pid <= 0:
return None
if IS_WINDOWS:
return _pid_start_time_windows(pid)
try:
# lstart is the absolute start time; etime/etimes are relative to now
# and so change between the write and the read. Portable across the
# macOS and Linux ps implementations.
out = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
if out.returncode != 0:
return None
stamp = out.stdout.strip()
return stamp or None
def _pid_start_time_windows(pid):
"""GetProcessTimes' creation time, via the observe-only handle."""
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return None
try:
creation = wintypes.FILETIME()
rest = (wintypes.FILETIME(), wintypes.FILETIME(), wintypes.FILETIME())
ok = kernel32.GetProcessTimes(handle, ctypes.byref(creation),
*[ctypes.byref(x) for x in rest])
if not ok:
return None
return "%d:%d" % (creation.dwHighDateTime, creation.dwLowDateTime)
finally:
kernel32.CloseHandle(handle)
def _same_process(pid, recorded_start):
"""Whether `pid` is still the process that `recorded_start` came from.
Both halves must hold and be knowable. A live pid with no recorded
fingerprint is the pre-upgrade state file: it cannot be proven ours, and
the penalty for guessing wrong is SIGTERM to an unrelated process group,
so it is refused rather than adopted.
"""
if not _pid_alive(pid):
return False
if not recorded_start:
return False
return _pid_start_time(pid) == recorded_start
def _terminate_pid_group(pid):
"""Stop an adopted run reached only by its pid, tree and all.
+117 -3
View File
@@ -84,6 +84,85 @@ fn resolve_python(user_data: Option<&Path>) -> Result<PathBuf, String> {
Err(format!("no usable Python found. Tried: {}", tried.join("; ")))
}
/// Bring an existing venv up to the current `ENGINE_PACKAGES` if it drifted.
///
/// The venv is built once, on first run, and then left alone -- so a fix
/// shipped in `praisonaiagents` after that never reaches the user, and raising
/// the pin in `ENGINE_PACKAGES` changes only what *new* installs get. This
/// compares a stamp written beside the venv against the current requirement
/// and, when they differ, re-runs the single install step (uv is near-instant
/// when nothing changed) and rewrites the stamp.
///
/// Returns whether an install was attempted, so the caller can label the tray.
/// Best effort throughout: a failure here must not block starting the engine,
/// because a stale-but-working engine is better than no engine, and the UI
/// reports the truth of what version is actually running.
///
/// `python` is the interpreter startup resolved. Reconciliation only touches
/// the venv that interpreter belongs to: `python_candidates` may resolve a
/// `PRAISONAI_PYTHON` override or a checkout venv ahead of `data/venv`, and
/// installing into `data/venv` in that case would modify an environment the
/// engine is not running from -- or fail every launch against a `data/venv`
/// that was never provisioned. The stamp lives beside that same venv, so a
/// non-`data/venv` interpreter is left untouched: its owner manages its
/// packages.
/// Returns whether packages were actually installed. A `true` means the venv's
/// code changed underneath any engine still running from it, so the caller must
/// not adopt that stale process: it is still executing the old package code and
/// only a restart picks up the new one.
fn reconcile_engine_packages(data: &Path, python: &Path, app: &tauri::AppHandle) -> bool {
use praisonai_desktop_core::provision::{
deps_step, locate_uv, needs_reprovision, stamp_contents, stamp_path, uv_candidates,
venv_python, Uv, ENGINE_PACKAGES,
};
// Only reconcile the venv we own and provisioned. If startup picked some
// other interpreter, its packages are not ours to rewrite.
if python != venv_python(data, Platform::current()) {
return false;
}
let stamp = std::fs::read_to_string(stamp_path(data)).ok();
if !needs_reprovision(stamp.as_deref(), ENGINE_PACKAGES) {
return false;
}
let Some(home) = home_dir() else { return false };
let platform = Platform::current();
let uv = match locate_uv(&uv_candidates(&home, data, platform), |p| p.is_file()) {
Uv::Found(p) => p,
// No uv means no first-run provision has completed; there is nothing to
// reconcile, and fetching one here would turn a normal launch into a
// download.
Uv::Fetch => return false,
};
let step = deps_step(&uv, data, ENGINE_PACKAGES, platform);
let mut command = std::process::Command::new(&step.program);
no_console(&mut command);
// "updating…" is shown only around the install that is actually running,
// and "starting…" restored before returning -- so no path out of here
// leaves the tray claiming an update that has finished or never ran.
praisonai_desktop_core::tray::set_engine_label(app, "Engine: updating\u{2026}");
let installed = match command.args(&step.args).output() {
Ok(out) if out.status.success() => {
// Only stamp on success: a failed install must be retried next
// launch, not silently recorded as done.
let _ = std::fs::write(stamp_path(data), stamp_contents(ENGINE_PACKAGES));
true
}
Ok(out) => {
eprintln!(
"[praisonai] package update failed, starting with what is installed: {}",
String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("")
);
false
}
Err(e) => {
eprintln!("[praisonai] could not run package update: {e}");
false
}
};
praisonai_desktop_core::tray::set_engine_label(app, "Engine: starting\u{2026}");
installed
}
/// Build the engine's Python environment, reporting each step as it starts.
///
/// Emits `provision` events rather than returning a lump at the end: the whole
@@ -232,6 +311,14 @@ async fn provision_engine(app: tauri::AppHandle) -> Result<String, String> {
if !py.is_file() {
return Err(format!("finished, but {} is not there", py.display()));
}
// Record what was installed, so a later launch can tell whether the venv is
// still current or predates a raised requirement and needs re-applying.
// Best effort: a venv that provisioned correctly must not be reported as a
// failure because a note beside it could not be written.
let _ = std::fs::write(
praisonai_desktop_core::provision::stamp_path(&data),
praisonai_desktop_core::provision::stamp_contents(ENGINE_PACKAGES),
);
Ok(py.display().to_string())
}
@@ -262,21 +349,48 @@ fn engine_status(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> En
// from Tauri package metadata, which the release workflow derives from the
// tag, rather than from Cargo.toml's development-only package version.
let shell_version = app.package_info().version.to_string();
// A resolved Python means a venv exists. Before starting the engine from
// it, bring it up to the current package requirement if it drifted -- the
// venv is provisioned once and otherwise never revisited, so a library fix
// shipped after first run would never reach this user. On failure the
// engine still starts, reporting whatever version is actually installed.
// `reconcile_engine_packages` shows "updating…" itself only while an
// install actually runs, and always restores "starting…" on return -- so a
// no-drift launch, a non-owned interpreter, or a subsequent `resolve_engine`
// failure never strands the tray on "updating…" reporting an update that is
// not running. It returns whether it actually installed: a fresh install
// means any engine still running from this venv is executing the old code,
// so that process must be reaped and restarted rather than adopted.
let reinstalled = match user_data.as_deref() {
Some(dir) => reconcile_engine_packages(dir, &python, &app),
None => false,
};
// Reap or adopt whatever the last run left. Neither `Drop` nor the reap on
// exit runs when the shell is killed by a signal, and that was observed:
// `kill -TERM` on the app left the Python child alive with a lockfile still
// claiming it, and the next launch started a second engine beside it.
//
// When packages were just reinstalled, adoption is suppressed: the survivor
// imported the old code at its own start and cannot see the new files, so
// it is killed and its lock cleared, and the fresh `start` below launches
// one that loads what was installed. Nothing is adopted stale.
if let Some(dir) = user_data.as_deref() {
let venv = venv_root_for_python(&python, &RealFs, Platform::current())
.map(|l| l.root.display().to_string())
.unwrap_or_default();
match reclaim(
// A fresh install forces the survivor to be treated as unhealthy: the
// probe closure returns `false`, so a matching engine is killed and its
// lock cleared (`KillAndRespawn`) rather than adopted, and the PID-reuse
// guard in `reclaim` still protects an unrelated process on the same
// PID. Otherwise probe for real and adopt a healthy survivor.
let decision = reclaim(
dir,
&python.display().to_string(),
&venv,
|port| supervisor::probe_health(port, &shell_version),
|port| !reinstalled && supervisor::probe_health(port, &shell_version),
kill_pid,
) {
);
match decision {
Decision::Adopt { port } => {
log_decision(&Decision::Adopt { port });
praisonai_desktop_core::tray::set_engine_label(
@@ -186,6 +186,50 @@ pub fn plan(uv: &Path, data_dir: &Path, packages: &[&str], platform: Platform) -
steps
}
/// Just the dependency-install step, for re-applying a changed package set into
/// a venv that already exists. This is the last step of `plan`, reused so the
/// re-provision path and the first-run path cannot disagree about how packages
/// are installed (one resolution, targeting this venv's interpreter).
pub fn deps_step(uv: &Path, data_dir: &Path, packages: &[&str], platform: Platform) -> Step {
plan(uv, data_dir, packages, platform)
.into_iter()
.find(|s| s.id == "deps")
.expect("plan always contains a deps step")
}
/// The stamp recording which packages were last installed into the venv.
///
/// Kept beside the venv, not in the data directory, so deleting the venv to
/// repair it also clears the record -- a stamp that outlived its venv would
/// claim packages that are no longer installed.
pub fn stamp_path(data_dir: &Path) -> PathBuf {
venv_dir(data_dir).join(".praisonai-packages")
}
/// The stamp's contents for a given package set: one requirement per line.
///
/// Order-independent, so a reordering of `ENGINE_PACKAGES` alone does not force
/// a reinstall, but any added, removed, or changed requirement does.
pub fn stamp_contents(packages: &[&str]) -> String {
let mut lines: Vec<&str> = packages.to_vec();
lines.sort_unstable();
let mut out = lines.join("\n");
out.push('\n');
out
}
/// Whether the venv must be brought up to `packages`, given whatever stamp was
/// found (or `None` if there is no stamp yet).
///
/// A missing stamp means an install that predates stamping: treat it as drift
/// so those users get the current package set once, rather than never.
pub fn needs_reprovision(existing_stamp: Option<&str>, packages: &[&str]) -> bool {
match existing_stamp {
Some(found) => found != stamp_contents(packages),
None => true,
}
}
/// What the engine needs to import. Kept here so the provisioning plan and the
/// failure message cannot disagree about it.
// Pinned to a floor, not left open.
@@ -360,4 +404,49 @@ mod tests {
assert!(PYTHON_VERSION.starts_with("3."), "{PYTHON_VERSION}");
assert!(!PYTHON_VERSION.contains("latest"));
}
#[test]
fn the_reprovision_step_is_the_plans_deps_step() {
// Re-applying a changed package set must install exactly as first-run
// does: one resolution, targeting this venv's interpreter.
let step = deps_step(Path::new("/uv"), Path::new("/data"), ENGINE_PACKAGES, Platform::Mac);
assert_eq!(step.id, "deps");
let i = step.args.iter().position(|a| a == "--python").unwrap();
assert_eq!(
step.args[i + 1],
venv_python(Path::new("/data"), Platform::Mac).display().to_string()
);
for p in ENGINE_PACKAGES {
assert!(step.args.iter().any(|a| a == p), "{p} missing");
}
}
#[test]
fn the_stamp_sits_inside_the_venv_so_deleting_it_clears_the_record() {
let p = stamp_path(Path::new("/data"));
assert!(p.starts_with(venv_dir(Path::new("/data"))), "{p:?}");
}
#[test]
fn reordering_the_packages_is_not_drift_but_changing_one_is() {
// The stamp is order-independent, so shuffling ENGINE_PACKAGES alone
// does not reinstall; a version bump or an added name does.
let stamp = stamp_contents(&["praisonaiagents>=1.7.2", "b"]);
assert!(!needs_reprovision(Some(&stamp), &["b", "praisonaiagents>=1.7.2"]));
assert!(needs_reprovision(Some(&stamp), &["praisonaiagents>=1.7.3", "b"]));
assert!(needs_reprovision(Some(&stamp), &["praisonaiagents>=1.7.2"]));
}
#[test]
fn no_stamp_is_treated_as_drift_so_pre_stamp_installs_get_updated_once() {
// An install made before stamping existed has no record; those users
// must get the current set once, not never.
assert!(needs_reprovision(None, ENGINE_PACKAGES));
}
#[test]
fn a_matching_stamp_is_not_drift_so_startup_stays_fast() {
let stamp = stamp_contents(ENGINE_PACKAGES);
assert!(!needs_reprovision(Some(&stamp), ENGINE_PACKAGES));
}
}
+20 -1
View File
@@ -1048,13 +1048,32 @@ async function refreshChats(){
if(!PORT) return;
try{
const {chats}=await (await fetch(`http://127.0.0.1:${PORT}/chats`)).json();
// Standing instructions per project, so the label can show one is set and
// clicking it edits it. A failure here must not blank the sidebar.
let projInstr={};
try{ projInstr=(await (await fetch(`http://127.0.0.1:${PORT}/projects`)).json()).instructions||{}; }catch{}
chatsEl.innerHTML='';
const groups={};
for(const c of chats){ (groups[c.project||'']??=[]).push(c); }
const order=Object.keys(groups).sort((a,b)=>a===''?1:b===''?-1:a.localeCompare(b));
for(const g of order){
if(g){ const lbl=document.createElement('div'); lbl.className='sectlbl';
lbl.style.padding='.7rem .75rem .2rem'; lbl.textContent=g;
lbl.style.padding='.7rem .75rem .2rem'; lbl.style.cursor='pointer';
lbl.textContent=g+(projInstr[g]?' \u2699':'');
lbl.title='Edit project instructions';
lbl.tabIndex=0; lbl.setAttribute('role','button');
const editInstr=async()=>{
const text=await askText(
`Instructions for project \u201c${g}\u201d (prepended to every chat here):`,
projInstr[g]||'');
if(text===null) return;
await fetch('http://127.0.0.1:'+PORT+'/projects',{method:'POST',
headers:{'content-type':'application/json'},
body:JSON.stringify({project:g,instructions:text})});
refreshChats();
};
lbl.onclick=editInstr;
lbl.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); editInstr(); } };
chatsEl.appendChild(lbl); }
for(const c of groups[g]){
const d=document.createElement('div');
@@ -1395,3 +1395,51 @@ test("a FULLY present Tauri global is treated as present -- the pair", () => {
});
assert.equal(both.isPresent(), true);
});
// ---- the keyboard, on the shell that actually runs on a phone ---------------
test("the TAURI shell reports the keyboard height, without any native event", async () => {
// `keyboardHeightPx` was a hard 0 whose only writer was the native
// `keyboard-height` event -- and nothing emits it: src-tauri/src/lib.rs's
// `on_window_event` is an empty closure, left unwired until the mobile
// targets are initialised. On a device --keyboard-height stayed 0 forever,
// and app.css pins #root to `position: fixed; inset: 0` so the layout
// viewport does not shrink either. The composer sat under the keyboard the
// moment anyone tapped it -- the first thing anyone does with the app.
//
// The WEB shell had this right all along. Nothing compared the two.
const window = createFakeWindow();
const probe = probeBridge();
const shell = createTauriShell({ bridge: probe.bridge, view: window.window });
await Promise.resolve();
assert.equal(shell.keyboardHeightPx, 0, "no keyboard to begin with");
const heights: number[] = [];
shell.onKeyboardHeightChanged((px) => heights.push(px));
window.setKeyboardHeight(320);
assert.equal(shell.keyboardHeightPx, 320, "the shell must see the keyboard open");
assert.deepEqual(heights, [320], "and tell its subscribers exactly once");
window.setKeyboardHeight(0);
assert.equal(shell.keyboardHeightPx, 0, "and see it close");
assert.deepEqual(heights, [320, 0]);
});
test("a native keyboard height takes over from the viewport reading", async () => {
// The native event fires THROUGH the transition; the viewport only at its
// ends. Native wins where it exists -- but two live writers would race on
// every keyboard animation, so the viewport listener is dropped once a
// native height arrives.
const window = createFakeWindow();
const probe = probeBridge();
const shell = createTauriShell({ bridge: probe.bridge, view: window.window });
await Promise.resolve();
probe.emit("keyboard-height", 291);
assert.equal(shell.keyboardHeightPx, 291, "native is authoritative");
window.setKeyboardHeight(320);
assert.equal(shell.keyboardHeightPx, 291, "the viewport must not fight the native source");
});
@@ -44,6 +44,11 @@ import type {
Unsubscribe,
} from "../../../core/src/ports/shell.ts";
import { createTauriBridge, type TauriBridge } from "./bridge.ts";
// The keyboard reading is identical on both platforms and already reasoned
// through in the web shell (pinch-zoom vs a real keyboard, overscroll, the
// offsetTop term). Importing it keeps one implementation rather than a copy
// that drifts.
import { readKeyboardHeight } from "../web/shell.ts";
/**
* A ShellPort plus one observable.
@@ -217,6 +222,9 @@ export interface TauriShellDeps {
readonly bridge?: TauriBridge;
/** Defaults to the DOM, or to nothing when there is no DOM. */
readonly insetSource?: InsetSource;
/** The window whose `visualViewport` reports the keyboard. Defaults to the
* real one; injected in tests so the path runs without a phone. */
readonly view?: Window;
}
export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
@@ -228,7 +236,24 @@ export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
const insetSubs = new Set<(insets: SafeAreaInsets) => void>();
const keyboardSubs = new Set<(heightPx: number) => void>();
let keyboardHeightPx = 0;
// Seeded and driven from `visualViewport`, exactly as the web shell does.
//
// This was a hard `0` whose only writer was the native `keyboard-height`
// event -- and nothing emits that event: `src-tauri/src/lib.rs`'s
// `on_window_event` is an empty closure, left unwired until the mobile
// targets are initialised. So on a device `--keyboard-height` stayed 0
// forever, and because `app.css` pins `#root` to `position: fixed; inset: 0`
// the layout viewport does not shrink either. The composer sat underneath
// the keyboard the instant anyone tapped it -- the first thing anyone does.
//
// WKWebView and Android WebView both implement `visualViewport`, so the web
// shell's reading works here and needs no native code. The native event
// stays wired below and overrides this the moment it starts arriving: it is
// the better source, but it must not be the ONLY source while it does not
// exist.
const view = deps.view ?? (globalThis as { window?: Window }).window;
let keyboardHeightPx = view === undefined ? 0 : readKeyboardHeight(view);
const lifecycleSubs = new Set<(phase: LifecyclePhase) => void>();
// An ARRAY, not a Set: back handlers are a stack and the most recently
// registered gets first refusal. A Set has no defined order, so a modal
@@ -302,8 +327,34 @@ export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
publishInsets(coerceInsets(payload) ?? readInsets(source));
});
// The web path, live. `visualViewport` fires `resize` and `scroll` as the
// keyboard animates, which is what makes the composer track it rather than
// jump once at the end. Removed the moment a native height arrives, so the
// two sources never fight -- native is authoritative where it exists.
let viewportOff: Unsubscribe | null = null;
if (view?.visualViewport !== undefined && view.visualViewport !== null) {
const viewport = view.visualViewport;
const onViewport = (): void => {
const height = readKeyboardHeight(view);
if (height === keyboardHeightPx) return;
keyboardHeightPx = height;
for (const cb of keyboardSubs) cb(height);
};
viewport.addEventListener("resize", onViewport);
viewport.addEventListener("scroll", onViewport);
viewportOff = () => {
viewport.removeEventListener("resize", onViewport);
viewport.removeEventListener("scroll", onViewport);
};
}
attach(EVENTS.keyboard, (payload) => {
const height = coerceKeyboardHeight(payload);
// A native height has arrived, so stop reading the viewport: the native
// source fires through the transition and the viewport one only at its
// ends, and two writers would race on every keyboard animation.
viewportOff?.();
viewportOff = null;
// Snapshot before notifying, per the port's ordering rule.
keyboardHeightPx = height;
// Unconditional. Fires through the transition, not just at its end, and
@@ -48,6 +48,13 @@ export function createFakeWindow(): FakeWindow {
set.add(cb);
map.set(type, set);
};
/** The other half of `on`. A real `visualViewport` has it, and this fake did
* not -- so a listener could be added and never removed, and any adapter
* that unsubscribes threw here instead of being exercised. */
const off = (map: Map<string, Set<(e?: unknown) => void>>) =>
(type: string, cb: (e?: unknown) => void) => {
map.get(type)?.delete(cb);
};
const fire = (map: Map<string, Set<(e?: unknown) => void>>, type: string) => {
for (const cb of [...(map.get(type) ?? [])]) cb();
};
@@ -73,6 +80,7 @@ export function createFakeWindow(): FakeWindow {
},
offsetTop: 0,
addEventListener: on(vpListeners),
removeEventListener: off(vpListeners),
};
},
document: {
+29 -1
View File
@@ -69,6 +69,14 @@ html, body {
}
.screen { display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* An AUTHOR `display` beats the user-agent's `[hidden] { display: none }`, so
the line above silently defeated `el.hidden = true` in app/src/mount.ts and
every screen rendered at once -- measured in headless Chrome: after tapping
Settings the chat screen was still on screen at half height with the
settings list stacked underneath, and each navigation halved the viewport
again. ui/src/screens.ts's mount/hide/unmount logic was correct throughout;
only this stylesheet broke it. */
.screen[hidden] { display: none; }
.topbar {
display: flex;
@@ -134,7 +142,14 @@ html, body {
/* main.ts sets this from the shell's live keyboard height. It is a padding
rather than a transform so the transcript's scroll height shrinks with it
and the last message stays reachable. */
padding-bottom: calc(var(--keyboard-height, 0px) + var(--safe-area-inset-bottom) + .5rem);
/* NOT `+ var(--safe-area-inset-bottom)`. `--keyboard-height` is written from
`geometryOf`, which is already `max(keyboard, insets.bottom)` -- adding the
inset back double-counts it. ui/src/layout/insets.ts calls that sum "the
single most recognisable 'web page in a box' tell there is", and it was
reintroduced here: measured 76px of padding with a 34px home indicator
where 42px is correct, and a keyboard's height of dead space above the
keyboard once one opens. */
padding-bottom: calc(var(--keyboard-height, 0px) + .5rem);
}
.composer textarea {
flex: 1;
@@ -178,6 +193,19 @@ button:disabled { opacity: .45; cursor: default; }
.setting .help { color: var(--soft); font-size: .84rem; }
.setting input, .setting select { font: inherit; font-size: 16px; padding: .45rem .55rem;
border: 1px solid var(--rule); border-radius: 8px; background: var(--panel); color: inherit; }
/* A refused setting, next to the field it was refused for. The row builder in
main.ts renders `.row-setting`, so the rules above (written against a
`.setting` class nothing emits) do not reach it -- these are matched on the
class names the app actually paints. `[hidden]` is stated explicitly for the
same reason `.screen[hidden]` is: an author `display` declaration beats the
user agent's, and an always-visible empty error box beside every field reads
as a rendering fault. */
.row-setting { display: flex; flex-direction: column; gap: .25rem; }
.row-setting .setting-input { font: inherit; font-size: 16px; padding: .45rem .55rem;
border: 1px solid var(--rule); border-radius: 8px; background: var(--panel); color: inherit; }
.setting-error { display: block; margin: .1rem 0 0; color: var(--warn); font-size: .84rem; }
.setting-error[hidden] { display: none; }
.section-heading { font-size: .72rem; letter-spacing: .1em; text-transform: uppercase;
color: var(--soft); margin: 1.2rem 0 .3rem; }
.warning { border: 1px solid var(--warn); color: var(--warn); border-radius: var(--radius);
+65 -9
View File
@@ -496,10 +496,39 @@ test("an engine answering 200 with ok:false is refused at BOOT, not mid-turn", a
engines: () => [probedChoice("remote", () => probeHealth(http, "http://engine.test"))],
}));
assert.equal(result.ok, false, "boot must refuse an unhealthy engine");
// The probe RUNS -- that is what this file's predecessor was written for, and
// it stands. What changed is the verdict: an unhealthy engine may recover, so
// it is reported rather than refused. Making it fatal meant a phone with no
// desktop engine to reach could not open the app at all, and the user could
// not correct the address because Settings is inside the app that would not
// start. `main.ts` renders `notReady` as a warning and announces it.
assert.equal(result.ok, true, "a recoverable unreadiness must not stop the app");
if (!result.ok) return;
assert.equal(result.notReady?.reason, "unhealthy", "but the app must be told");
assert.match(String(result.notReady?.detail), /false/, "and told what the engine said");
await result.app.dispose();
});
test("a PERMANENT unreadiness is still refused at boot", async () => {
// The pair, and the line between the two: a version mismatch does not fix
// itself, so booting into it only defers the same failure to the user's
// first message. `readiness.ts` marks exactly these `retryable: false`.
const http = createFakeHttp();
http.on("/health", () => ({
status: 200,
headers: {},
body: streamOf(JSON.stringify({ ok: true, version: 1 })),
}));
const result = await createApp(deps({
engineId: "remote",
engines: () => [probedChoice("remote", () => probeHealth(http, "http://engine.test"))],
}));
assert.equal(result.ok, false, "a too-old engine must refuse to boot");
if (result.ok) return;
assert.equal(result.reason, "unhealthy");
assert.equal(result.retryable, true, "an unhealthy engine may recover, so a caller can poll");
assert.equal(result.reason, "version_mismatch");
assert.equal(result.retryable, false, "waiting will not fix a version mismatch");
});
test("an engine that cannot be reached at all is refused at boot, and retryably", async () => {
@@ -512,10 +541,13 @@ test("an engine that cannot be reached at all is refused at boot, and retryably"
engines: () => [probedChoice("remote", () => probeHealth(http, "http://engine.test"))],
}));
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.reason, "http_status");
assert.equal(result.retryable, true);
// Retryable, so the app STARTS and reports it. This is the case that made
// the hard gate untenable: on a phone there is usually no desktop engine to
// reach, so refusing here meant the app never opened.
assert.equal(result.ok, true, "an unreachable engine must not stop the app");
if (!result.ok) return;
assert.equal(result.notReady?.reason, "http_status");
await result.app.dispose();
});
test("a healthy probe lets the boot through -- the pair", async () => {
@@ -548,7 +580,7 @@ test("an engine with no probe boots unchanged -- probing is optional", async ()
if (result.ok) await result.app.dispose();
});
test("an engine rejected by its probe is disposed, not left holding a socket", async () => {
test("an engine PERMANENTLY rejected by its probe is disposed, not left holding a socket", async () => {
// Same guarantee a protocol mismatch already has: a refused engine that keeps
// its socket is a leak that only surfaces as a second, unrelated failure.
let disposed = false;
@@ -561,12 +593,36 @@ test("an engine rejected by its probe is disposed, not left holding a socket", a
await createApp(deps({
engineId: "remote",
engines: () => [
{ id: "remote", create: () => engine, probe: async () => ({ ready: false, reason: "unhealthy", detail: "false", retryable: true }) },
{ id: "remote", create: () => engine, probe: async () => ({ ready: false, reason: "version_mismatch", detail: "too old", retryable: false }) },
],
}));
assert.equal(disposed, true);
});
test("an engine kept despite a RETRYABLE rejection is NOT disposed", async () => {
// The pair, and the reason the two cases differ: a retryable unreadiness
// hands the engine over so the app can use it once the remote comes up.
// Disposing it here would leave the app holding a dead engine it can never
// retry through.
let disposed = false;
const inner = createScriptedEngine({ script: SCRIPTS.happy });
const engine: AgentEnginePort = {
...inner,
id: "remote",
dispose: async () => void (disposed = true),
};
const result = await createApp(deps({
engineId: "remote",
engines: () => [
{ id: "remote", create: () => engine, probe: async () => ({ ready: false, reason: "unhealthy", detail: "false", retryable: true }) },
],
}));
assert.equal(disposed, false, "the engine is still the one the app will use");
assert.equal(result.ok, true);
if (result.ok) await result.app.dispose();
assert.equal(disposed, true, "and it is disposed when the app is");
});
test("selectEngine runs the probe only after the protocol check passes", async () => {
// The probe opens a socket to the remote. Running it before the cheap,
// I/O-free protocol check would reach the network for an engine this build
+11 -1
View File
@@ -74,7 +74,14 @@ export interface App {
}
export type BootResult =
| { readonly ok: true; readonly app: App }
| {
readonly ok: true;
readonly app: App;
/** The app started, but the engine is not answering yet. A caller must
* SAY so -- silently booting into an engine that cannot reply is how a
* first message fails with no explanation. */
readonly notReady?: { readonly reason: string; readonly detail: string };
}
| {
readonly ok: false;
readonly reason: string;
@@ -209,6 +216,9 @@ export async function createApp(deps: AppDeps): Promise<BootResult> {
return {
ok: true,
// Attached, not swallowed: `selectEngine` hands back an engine that is not
// answering yet rather than refusing to boot, and the caller has to say so.
...(selection.notReady === undefined ? {} : { notReady: selection.notReady }),
app: {
engine,
controller,
+65
View File
@@ -0,0 +1,65 @@
/**
* The stylesheet, checked against what the app does to it.
*
* `app.css` is not typechecked, not bundled through the gate, and not covered
* by any test -- so three rules that decide whether the app is usable on a
* phone were unasserted. Each of these was a measured defect, not a hypothetical.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const raw = readFileSync(join(import.meta.dirname, "../app.css"), "utf8");
/** Comments explain the rules and sometimes name the very token a rule must
* NOT contain, so they are stripped before matching. */
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
const main = readFileSync(join(import.meta.dirname, "main.ts"), "utf8")
.replace(/\/\/.*$/gm, "");
test("a hidden screen is actually hidden", () => {
// `.screen { display: flex }` is an AUTHOR declaration and beats the user
// agent's `[hidden] { display: none }`, so `el.hidden = true` in mount.ts did
// nothing. Measured in headless Chrome: after tapping Settings the chat
// screen was still on screen at half height with the settings list stacked
// under it, and every navigation halved the viewport again.
assert.match(css, /\.screen\[hidden\]\s*\{[^}]*display:\s*none/, "app.css must hide a hidden screen");
});
test("the composer does not add the bottom inset twice", () => {
// `--keyboard-height` is written from `geometryOf`, which is already
// `max(keyboard, insets.bottom)`. Adding `--safe-area-inset-bottom` back is
// the `bottom + keyboard` sum that ui/src/layout/insets.ts calls "the single
// most recognisable 'web page in a box' tell there is".
// Each declaration taken up to its semicolon, rather than by a paren-matching
// regex: `calc(var(a) + var(b))` has two nested groups and a `[^)]*` pattern
// stops at the first `)`, which is why the first version of this test passed
// against the bug it was written for.
const declarations = [...css.matchAll(/padding-bottom\s*:([^;]*);/g)].map((m) => m[1] ?? "");
const keyboardDecls = declarations.filter((d) => d.includes("--keyboard-height"));
assert.ok(keyboardDecls.length > 0, "the composer must follow the keyboard at all");
for (const d of keyboardDecls) {
assert.ok(
!d.includes("--safe-area-inset-bottom"),
`--keyboard-height is already max(keyboard, inset); adding the inset double-counts it:${d}`,
);
}
});
test("the composer keeps a gutter when the inset is zero", () => {
// main.ts writes padding-inline INLINE, which beats the stylesheet. Writing
// the bare inset put the textarea flush to the screen edge at a 320px
// viewport while every other element kept its 12px gutter.
const starts = /padding-inline-start",\s*`([^`]*)`/.exec(main)?.[1] ?? "";
const ends = /padding-inline-end",\s*`([^`]*)`/.exec(main)?.[1] ?? "";
for (const [side, value] of [["start", starts], ["end", ends]] as const) {
assert.match(value, /\+\s*\.75rem/, `the ${side} gutter must survive a zero inset: ${value}`);
}
});
test("the viewport bridge the safe areas depend on is intact -- the pair", () => {
// The three rules above are all about safe-area handling; if the bridge that
// supplies the values were removed they would all pass while measuring zero.
assert.match(css, /--safe-area-inset-bottom:\s*env\(safe-area-inset-bottom/);
assert.match(css, /--safe-area-inset-top:\s*env\(safe-area-inset-top/);
});
+32 -10
View File
@@ -34,7 +34,14 @@ export interface EngineChoice {
}
export type EngineSelection =
| { readonly ok: true; readonly engine: AgentEnginePort }
| {
readonly ok: true;
readonly engine: AgentEnginePort;
/** The engine was selected but is not answering yet. Set only for a
* RETRYABLE unreadiness -- see the probe branch below for why that is
* not a refusal. */
readonly notReady?: { readonly reason: string; readonly detail: string };
}
| {
readonly ok: false;
readonly reason: "unknown_engine" | "protocol_mismatch" | Extract<Readiness, { ready: false }>["reason"];
@@ -99,15 +106,30 @@ export async function selectEngine(
if (choice.probe !== undefined) {
const readiness = await choice.probe();
if (!readiness.ready) {
// Dispose for the same reason a protocol mismatch does: an engine left
// holding a socket is a leak that only shows up as a second failure.
await engine.dispose();
return {
ok: false,
reason: readiness.reason,
detail: readiness.detail,
retryable: readiness.retryable,
};
// A PERMANENT unreadiness is a refusal: a version mismatch does not fix
// itself, so booting into it only defers the same failure to the user's
// first message.
if (readiness.retryable === false) {
// Dispose for the same reason a protocol mismatch does: an engine left
// holding a socket is a leak that only shows up as a second failure.
await engine.dispose();
return { ok: false, reason: readiness.reason, detail: readiness.detail, retryable: false };
}
// A RETRYABLE one is not. An engine that is still binding its socket, or
// a phone that has no desktop engine to reach at all, must not stop the
// app from starting: refusing here left the user on an error screen with
// no way back -- they cannot open Settings to change the address,
// because that is where the address is changed.
//
// Measured before this branch existed: with no `/health` route the whole
// app rendered "PraisonAI could not start: 404" and nothing else, and 17
// end-to-end tests went red on main.
//
// So the engine is handed over WITH its unreadiness attached. The app
// starts, says what is wrong, and a retry costs the user one tap instead
// of a reinstall.
return { ok: true, engine, notReady: { reason: readiness.reason, detail: readiness.detail } };
}
}
@@ -147,3 +147,56 @@ test("a delete-chat with no chat id is refused, not addressed at nothing", () =>
// chat is keyed "", at the wrong one.
assert.equal(intentFrom([{ dataset: { action: "delete-chat" }, disabled: false }]), null);
});
// ---- editing a setting -----------------------------------------------------
//
// The recovery path the remote-http default depends on. The settings screen
// renders real fields, and until now each one committed itself from inside its
// own `change` listener -- the one affordance in the app that did NOT go
// through this file. That is the arrangement the header argues against: the
// decision (which key, what was typed, and whether this element means anything
// at all) could only be exercised by synthesising events against a DOM.
test("a settings field resolves to a set-setting carrying its key AND what was typed", () => {
const intent = intentFrom([
{ dataset: { action: "set-setting", settingKey: "baseUrl" }, value: "http://10.0.0.7:9000" },
]);
assert.deepEqual(intent, { kind: "set-setting", key: "baseUrl", raw: "http://10.0.0.7:9000" });
});
test("a set-setting with no key is refused rather than aimed at nothing", () => {
// Same rule as delete-chat and navigate: an action that needs an id and has
// none must not resolve to a write against "" -- `settings.set` refuses an
// unknown key, but silently, and the user is told nothing.
assert.equal(intentFrom([{ dataset: { action: "set-setting" }, value: "x" }]), null);
});
test("a set-setting from an element with NO value is refused, not written as empty", () => {
// Only a field has a value. A tap that lands on the row's <label> and walks
// out to something carrying the action but no text must not be decoded as
// "the user cleared this setting" -- that would wipe the engine address on a
// stray tap, on the one screen whose job is to repair it.
assert.equal(intentFrom([{ dataset: { action: "set-setting", settingKey: "baseUrl" } }]), null);
});
test("an EMPTIED field is still a real edit, and decodes as an empty string", () => {
// The pair for the test above: `value: ""` is a person clearing a field, and
// refusing it too would make "" indistinguishable from "no field here" and
// leave a cleared setting silently ignored. Where the empty string is not a
// legal value, validateInput is what refuses it -- visibly.
assert.deepEqual(
intentFrom([{ dataset: { action: "set-setting", settingKey: "baseUrl" }, value: "" }]),
{ kind: "set-setting", key: "baseUrl", raw: "" },
);
});
test("a DISABLED settings field commits nothing", () => {
// A field goes disabled while a write is in flight; honouring that is what
// stops a second commit racing the first to the store.
assert.equal(
intentFrom([
{ dataset: { action: "set-setting", settingKey: "baseUrl" }, value: "x", disabled: true },
]),
null,
);
});
+31
View File
@@ -24,6 +24,17 @@ export type Intent =
| { readonly kind: "open-chat"; readonly chatId: string }
| { readonly kind: "delete-chat"; readonly chatId: string }
| { readonly kind: "navigate"; readonly route: string }
/**
* A settings field was committed.
*
* `raw` is the text exactly as the field held it -- unparsed and
* unvalidated, because deciding whether "9000" is a legal port for this key
* needs the def, and this file deliberately knows about no settings at all.
* `ui/src/settings/view-model.ts`'s `validateInput` is what turns it into a
* value, and the handler is where a refusal becomes something the user can
* see.
*/
| { readonly kind: "set-setting"; readonly key: string; readonly raw: string }
| { readonly kind: "retry" }
| { readonly kind: "copy" };
@@ -32,6 +43,16 @@ export interface Actionable {
/** `data-*` attributes, camelCased exactly as `HTMLElement.dataset` gives them. */
readonly dataset: Readonly<Record<string, string | undefined>>;
readonly disabled?: boolean;
/**
* What the control currently holds, for the elements that hold anything.
*
* Absent on every element that is not a field -- a `<div>` row, a `<span>`
* label, the section heading. That ABSENCE is load-bearing: it is the only
* thing separating "the user cleared this field" from "this element has no
* field in it", and conflating the two turns a stray tap into a wiped engine
* address on the one screen that exists to repair it.
*/
readonly value?: string;
}
const CHOICES: readonly string[] = ["allow", "always", "deny"];
@@ -89,6 +110,16 @@ export function intentFrom(chain: readonly Actionable[]): Intent | null {
const route = d["route"];
return route === undefined ? null : { kind: "navigate", route };
}
case "set-setting": {
// Both halves are required, and for different reasons. A missing key
// is the delete-chat rule again: a write aimed at "" is refused by the
// store anyway, but silently, and the user is told nothing. A missing
// VALUE means this element is not a field -- see `Actionable.value`.
const key = d["settingKey"];
if (key === undefined) return null;
const raw = el.value;
return raw === undefined ? null : { kind: "set-setting", key, raw };
}
default:
// An unknown action is not an error -- a newer template can carry one
// this build has never heard of -- but it must not silently resolve to
+551 -1
View File
@@ -7,9 +7,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { stopNotice } from "./main.ts";
import { defaultEngineIdFor, stopNotice } from "./main.ts";
import { en } from "../../ui/src/i18n/strings.ts";
test("every platform's first-launch default is the remote engine, for now", () => {
// Tempting to default a device to the in-process engine -- a phone cannot
// reach `http://127.0.0.1:8765` and the cleartext localhost address is
// refused by iOS ATS and Android -- but the in-process engine's module is
// ABSENT from the shipping webview: build-webview.mjs bundles only
// `main.ts` into `dist/app.js`, and the engine reaches praisonai-ts through a
// runtime-computed import left outside that bundle (#4437). So defaulting a
// device to it would swap the "not answering" warning (which names Settings
// as the fix) for a first prompt that fails "unavailable in this build" with
// no recovery -- the very brick registry.ts keeps the engine out of the
// picker to avoid. Until #4437 ships praisonai-ts inside the webview, remote
// is the honest default. `Platform["kind"]` also cannot tell desktop Tauri
// (`cargo tauri dev`) from a phone, so a `kind === "tauri"` default would
// strand the desktop/dev flow too.
assert.equal(defaultEngineIdFor("tauri"), ENGINE_REMOTE_HTTP);
assert.equal(defaultEngineIdFor("web"), ENGINE_REMOTE_HTTP);
});
test("a stop the engine REFUSED is announced", () => {
// Discarding the controller's boolean made a refused Stop indistinguishable
// from an accepted one: the button label flips off `turn.phase`, which
@@ -41,6 +59,7 @@ import { createFakeShell, PHONE_INSETS } from "../../testing/src/fake-shell.ts";
import { createFakeStorage } from "../../testing/src/fake-storage.ts";
import { createFakeSecrets } from "../../testing/src/fake-secrets.ts";
import { createFakeHttp, sseResponse, streamOf } from "../../testing/src/fake-http.ts";
import { PROTOCOL_VERSION } from "../../protocol/src/version.ts";
import { appEngines, mount } from "./main.ts";
import { ENGINE_PRAISONAI_TS, ENGINE_REMOTE_HTTP, SETTING_DEFS } from "./registry.ts";
import { createSettingsStore, facadeFor, type SettingsFacade } from "../../core/src/settings/store.ts";
@@ -729,3 +748,534 @@ test("the message field is labelled as the field, not as the button beside it",
assert.notEqual(box.getAttribute("aria-label"), en.actionSend, "not the button's name");
app?.dispose();
});
// ---- an unreachable engine must not be a dead app ---------------------------
test("the app STARTS when the engine is not answering, and says so", async () => {
// PR #4647 made the health probe a hard boot gate, and the whole app
// rendered "PraisonAI could not start: 404" -- 17 end-to-end tests went red
// on main, and on a phone with no desktop engine to reach the app simply
// would not open. The user cannot fix it either: the address is changed in
// Settings, and Settings is behind the app that will not start.
const { dom, platform } = harness(); // no /health route: the engine is unreachable
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null, "an unreachable engine must not stop the app from starting");
assert.ok(dom.find((n) => n.tagName === "TEXTAREA"), "the composer must be there");
assert.ok(dom.find((n) => String(n.dataset["action"]) === "send"), "and the send control");
const notice = dom.find((n) => n.className.includes("row-notice"));
assert.ok(notice, `the app must SAY the engine is not answering:\n${dom.text()}`);
assert.equal(notice.dataset["tone"], "warning");
// Assertive, because the user is about to type into something that cannot
// reply yet -- waiting politely for the queue means they find out by sending.
const shouted = dom.all().find((n) => n.getAttribute("aria-live") === "assertive");
assert.match(String(shouted?.textContent), /not answering yet/);
app?.dispose();
});
test("a HEALTHY engine boots with no warning -- the pair", async () => {
// Without this, an app that always warned would pass the test above and cry
// wolf on every launch.
const { dom, http, platform } = harness();
http.on("/health", () => ({
status: 200,
headers: {},
body: streamOf(JSON.stringify({ ok: true, version: PROTOCOL_VERSION })),
}));
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
assert.equal(
dom.find((n) => n.className.includes("row-notice")),
null,
"a healthy engine must not be reported as unhealthy",
);
app?.dispose();
});
test("a PERMANENT refusal still stops the app, with a name", async () => {
// The other half. A retryable unreadiness boots and warns; a protocol
// mismatch never resolves itself, so booting into it only defers the same
// failure to the user's first message. It must still be fatal.
//
// Version 1, not 99: a NEWER engine is deliberately accepted, because
// version.ts treats unknown fields as additive and refusing one would strand
// every shipped client on the day the engine ships first. Too OLD is the
// permanent refusal.
const { dom, http, platform } = harness();
http.on("/health", () => ({
status: 200,
headers: {},
body: streamOf(JSON.stringify({ ok: true, version: 1 })),
}));
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.equal(app, null, "a version mismatch must refuse to boot");
assert.match(dom.text(), /could not start/, dom.text());
assert.match(dom.text(), /too_old|engine=1|protocol/, "and it must name the reason");
});
// ---- the modules the barrel used to gate (issue #4635) ----------------------
//
// Nine view models had 128 tests and zero non-test importers: settings, the
// chat list, follow-the-stream, locale/direction and the composer were all
// maintained, tested and unreachable from `main.ts`. These drive the real
// `mount()` and assert each is now wired.
test("tapping Settings paints the settings screen", async () => {
// The acceptance test named in the issue. `router.subscribe` had no non-test
// caller, so pushing the settings route rendered nothing: the tap painted a
// blank and the next back gesture was consumed instead of exiting.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
dom.click(dom.find((n) => n.dataset["route"] === "settings") as never);
await settle();
assert.ok(
dom.find((n) => n.className.includes("screen-settings")),
`Settings painted nothing:\n${dom.text()}`,
);
// And the chat screen is hidden, not left underneath.
const chat = dom.find((n) => n.className === "screen");
assert.equal(chat?.hidden, true, "the chat screen must be hidden while settings shows");
app?.dispose();
});
test("the settings screen is EDITABLE, so a phone can point the engine somewhere reachable", async () => {
// The recovery the remote-http default depends on. A phone reaches no
// `127.0.0.1:8765`, so first launch warns that the engine is not answering --
// and the only fix is to change `baseUrl`. That was impossible: every setting
// rendered as a read-only <span>, and `facade.set` / `validateInput` had no
// caller, so an unreachable engine had no way back. The row is a real field
// now; committing it must PERSIST -- createApp loads settings first and builds
// the engine from them, so the change takes effect on the next launch, which
// is the recovery this unblocks.
const storage = createFakeStorage();
const { dom, platform } = harness({ storage });
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
dom.click(dom.find((n) => n.dataset["route"] === "settings") as never);
await settle();
// The baseUrl row must carry an editable control, not a read-only span.
const field = dom.find(
(n) =>
(n.tagName === "INPUT" || n.tagName === "SELECT") &&
n.getAttribute("aria-label") === "Engine address",
);
assert.ok(field, `the engine address must be editable:\n${dom.text()}`);
(field as { value: string }).value = "http://10.0.0.7:9000";
dom.change(field as never);
await settle();
// Persisted in the live facade AND written through to storage, so the next
// launch's `settings.load()` reads the address the user set, not the default.
assert.equal(app?.settings.get("baseUrl"), "http://10.0.0.7:9000", "the edit must persist in the facade");
const raw = await storage.read({ namespace: "settings", id: "app" });
assert.ok(
raw !== null && raw.includes("10.0.0.7:9000"),
`the edit must survive a relaunch:\n${String(raw)}`,
);
app?.dispose();
});
test("a setting the store REFUSES resets the field instead of showing a phantom value", async () => {
// `set` returns false for a value the def rejects, and `validateInput`
// returns null for one that will not parse. Either way the field must fall
// back to what is actually stored -- a screen showing a value the store never
// accepted is a setting the user believes they changed and did not.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
dom.click(dom.find((n) => n.dataset["route"] === "settings") as never);
await settle();
// The engine choice is a closed set (`choices: [remote-http]`), so an unknown
// id must be refused and the select must snap back.
const select = dom.find(
(n) => n.tagName === "SELECT" && n.getAttribute("aria-label") === "Engine",
);
assert.ok(select, "the engine choice must be a select");
(select as { value: string }).value = "not-a-real-engine";
dom.change(select as never);
await settle();
assert.equal(app?.settings.get("engineId"), ENGINE_REMOTE_HTTP, "a rejected choice must not persist");
assert.equal((select as { value: string }).value, ENGINE_REMOTE_HTTP, "the field must reset to what is stored");
app?.dispose();
});
test("a storage failure while editing a setting stays LOCAL, not fatal", async () => {
// `commit` was a fire-and-forget `void commit(...)`, and `settings.set`
// persists through StoragePort -- which rejects on a real device
// (SecurityError with site data blocked, QuotaExceededError). Left to float,
// that rejection reached the global crash handler and replaced the WHOLE app
// with the fatal screen; worse, the store had already committed the value to
// memory before persisting, so the field showed an address the next launch
// would never read. A failed write must stay local: the app keeps working and
// the field resets to what is actually stored.
const storage = createFakeStorage();
const { dom, platform } = harness({ storage });
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
dom.click(dom.find((n) => n.dataset["route"] === "settings") as never);
await settle();
const field = dom.find(
(n) =>
(n.tagName === "INPUT" || n.tagName === "SELECT") &&
n.getAttribute("aria-label") === "Engine address",
);
assert.ok(field, `the engine address must be editable:\n${dom.text()}`);
const before = app?.settings.get("baseUrl");
storage.failNext("QuotaExceededError: the storage is full");
(field as { value: string }).value = "http://10.0.0.7:9000";
dom.change(field as never);
await settle();
// The app must survive: the chat screen and composer are still reachable, and
// nothing escalated to the fatal "could not start" screen.
assert.ok(
dom.find((n) => n.tagName === "TEXTAREA" || n.tagName === "INPUT" || n.tagName === "SELECT"),
`a failed setting write must not take down the app:\n${dom.text()}`,
);
assert.equal(
/could not start/.test(dom.text()),
false,
"a failed settings write must not become the app-wide crash screen",
);
// The store rolled the value back, so the field shows what is actually
// stored -- not a phantom the next launch will not read.
assert.equal(app?.settings.get("baseUrl"), before, "a value that did not persist must not linger in memory");
assert.equal((field as { value: string }).value, String(before), "the field must reset to what is stored");
app?.dispose();
});
test("a route change MOVES focus to the new heading, and Back restores it", async () => {
// focus.ts computes where focus belongs on every route change and the app
// used to throw the answer away -- it read the target only to decide whether
// to announce, always as a "push", and never called `focus()`. So a
// keyboard or screen-reader user who opened Settings was left focused on the
// button they tapped (now on a hidden screen) or dropped to <body>, and Back
// never returned them to where they were.
// The fake shell is built directly, so `pressBack()` (a test-only driver, not
// part of ShellPort) is reachable to drive the OS back gesture.
const shell = createFakeShell(PHONE_INSETS);
const dom = createFakeDom();
const platform: Platform = {
shell, storage: createFakeStorage(), secrets: createFakeSecrets(),
http: createFakeHttp(), time: nodeTime(), kind: "web",
};
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
const toSettings = dom.find((n) => n.dataset["route"] === "settings");
assert.ok(toSettings, "no settings button");
dom.click(toSettings as never);
await settle();
// Focus is on the settings screen's heading, not on the hidden chat screen.
const focused = dom.activeElement();
assert.equal(
focused?.dataset["focusId"],
"heading:settings",
`a route change must move focus to the new heading, landed on: ${
focused?.dataset["focusId"] ?? "<nothing>"
}`,
);
// Back pops the route -- and restores focus to the control that opened it,
// so the user lands where they were rather than at the top of the chat.
shell.pressBack();
await settle();
assert.equal(
dom.activeElement(),
toSettings,
"Back must restore focus to the control that opened the screen",
);
app?.dispose();
});
test("the chat list is painted from the session, and a chat reopens", async () => {
// `session.list()` had no app caller and the chat list no way to be reached,
// so a conversation, once left, could never be reopened. The chats route must
// render the stored chats -- from the SESSION, the thing that had no reader --
// and tapping one must reopen it.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
// Store a conversation through the session directly. Persistence on a real
// turn is the in-process engine's job; the remote harness never records, so
// seed the session the list reads from. This is the object `list()` returns.
await app!.session.record("what is the capital of France", "Paris");
dom.click(dom.find((n) => n.dataset["route"] === "chats") as never);
await settle();
const list = dom.find((n) => n.className.includes("screen-chats"));
assert.ok(list, `the chat list painted nothing:\n${dom.text()}`);
const row = dom.find((n) => n.dataset["action"] === "open-chat");
assert.ok(row, `a stored conversation must be an openable row:\n${dom.text()}`);
// And opening it returns to the chat screen carrying that conversation.
dom.click(row as never);
await settle();
const chat = dom.find((n) => n.className === "screen");
assert.equal(chat?.hidden, false, "opening a chat must show the chat screen");
assert.match(dom.text(), /Paris/, "the reopened conversation must be painted");
app?.dispose();
});
test("a turn sent AFTER reopening a chat lands below the history, not above it", async () => {
// The reopened messages were appended as untracked <p> nodes while `render`
// was reset to empty, so the next turn reconciled from nothing and
// `applyOps` inserted its rows at index 0 -- ABOVE the restored history. The
// newest answer rendered above the older conversation, and the history sat
// outside `render` where no later reconcile could touch it. Painting the
// history THROUGH the reconciler keeps both turns in one coordinate system.
const { dom, http, platform } = harness();
http.on("/chat", () =>
sseResponse(
sse([
["start", { msg_id: "m1", run_id: "r1" }],
["delta", { msg_id: "m1", text: "the newest answer" }],
["end", { msg_id: "m1", user_index: 2, assistant_index: 3, versions: 1, active: 0 }],
]),
),
);
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
await app!.session.record("what is the capital of France", "Paris");
dom.click(dom.find((n) => n.dataset["route"] === "chats") as never);
await settle();
dom.click(dom.find((n) => n.dataset["action"] === "open-chat") as never);
await settle();
submit(dom, "and its population?");
await settle(120);
const transcript = dom.find((n) => n.className.includes("transcript"));
assert.ok(transcript, "no transcript");
const text = (transcript.children as { textContent: string }[]).map((c) => c.textContent).join(" | ");
// The restored history must survive the next turn -- it must not be wiped by
// a reconcile that never knew it existed.
assert.match(text, /capital of France/, `the user's original question was lost:\n${text}`);
assert.match(text, /Paris/, `the reopened answer was lost:\n${text}`);
assert.match(text, /the newest answer/, `the new turn did not render:\n${text}`);
// And order: the newest answer is LAST, below the history it followed.
const question = text.indexOf("capital of France");
const answer = text.indexOf("Paris");
const newest = text.indexOf("the newest answer");
assert.ok(
question < newest && answer < newest,
`the newest turn must render below the history, got order:\n${text}`,
);
app?.dispose();
});
test("a storage failure while the chat list loads stays LOCAL, not fatal", async () => {
// The list load was a floating `void (async ...)()` with no rejection
// handler. A StoragePort rejection -- SecurityError, QuotaExceeded -- reached
// the global crash handler and replaced the WHOLE app with the fatal screen,
// instead of showing a local error the user can back out of.
const { dom, storage, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null, "the app must boot before the list is even asked for");
storage.failNext("SecurityError: the operation is insecure");
dom.click(dom.find((n) => n.dataset["route"] === "chats") as never);
await settle();
// The chat screen and its composer must still be reachable: this was a local
// failure, not a boot failure.
assert.ok(dom.find((n) => n.tagName === "TEXTAREA"), `the app must survive a list failure:\n${dom.text()}`);
// And nothing must have escalated to the fatal "could not start" screen.
assert.equal(
/could not start/.test(dom.text()),
false,
"a failed chat list must not become the app-wide crash screen",
);
app?.dispose();
});
test("the composer field mirrors the composer view model", async () => {
// The raw <textarea> is backed by composer.ts now: typing updates the draft
// and enables Send, an empty field disables it. Draft persistence, the
// Enter policy and autosize all live in that one state.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
const box = dom.find((n) => n.tagName === "TEXTAREA") as never;
const send = () => dom.find((n) => n.dataset["action"] === "send") as never;
assert.equal((send() as { disabled: boolean }).disabled, true, "Send is disabled on an empty draft");
(box as { value: string }).value = "a question";
(box as { dispatch(t: string, e: unknown): void }).dispatch("input", {});
assert.equal((send() as { disabled: boolean }).disabled, false, "typing must enable Send");
app?.dispose();
});
test("the locale and direction are detected, not hardcoded to en", async () => {
// main.ts:173 passed `locale: "en"` as a literal, so every RTL user got an
// LTR layout and the #4607 direction fix was unreachable. An Arabic device
// must lay out right-to-left.
const { dom, platform } = harness();
const app = await mount({
root: dom.root as never,
platform,
now: () => 1,
newChatId: () => "c1",
locales: ["ar-EG"],
});
assert.equal(dom.root.getAttribute("dir"), "rtl", "an Arabic locale must lay out RTL");
app?.dispose();
});
test("an English device still lays out left-to-right -- the pair", async () => {
const { dom, platform } = harness();
const app = await mount({
root: dom.root as never,
platform,
now: () => 1,
newChatId: () => "c1",
locales: ["en-GB"],
});
assert.equal(dom.root.getAttribute("dir"), "ltr");
app?.dispose();
});
// ---- the recovery path, end to end -----------------------------------------
//
// A phone reaches no `127.0.0.1:8765`, so first launch warns that the engine
// is not answering and points at Settings. Everything from that warning to a
// working engine has to hold, and two links of it did not: a refused value
// vanished with no explanation, and a corrected address changed nothing until
// the app was force-quit and relaunched.
const settingField = (dom: ReturnType<typeof createFakeDom>, label: string) =>
dom.find(
(n) => (n.tagName === "INPUT" || n.tagName === "SELECT") && n.getAttribute("aria-label") === label,
);
const openSettings = async (dom: ReturnType<typeof createFakeDom>): Promise<void> => {
dom.click(dom.find((n) => n.dataset["route"] === "settings") as never);
await settle();
};
test("a corrected engine address redirects the very NEXT message, with no relaunch", async () => {
// The whole point of making the screen editable. `enginesFor` read `baseUrl`
// once at construction and the app builds its engine once at boot, so the
// address the user typed was persisted, displayed, and then ignored for the
// rest of the session -- every message still went to the unreachable default.
const { dom, http, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
assert.notEqual(app, null);
await openSettings(dom);
const field = settingField(dom, "Engine address");
assert.ok(field, `the engine address must be editable:\n${dom.text()}`);
field.value = "http://10.0.0.7:9000";
dom.change(field as never);
await settle();
submit(dom, "hello");
await settle();
const chats = http.sent.filter((r) => r.url.endsWith("/chat")).map((r) => r.url);
assert.ok(chats.length > 0, "the message never reached the transport at all");
assert.equal(
chats.at(-1),
"http://10.0.0.7:9000/chat",
`the turn still went to the old address: ${chats.join(", ")}`,
);
app?.dispose();
});
test("a REFUSED setting says so on screen, instead of the field quietly snapping back", async () => {
// A field that resets and says nothing is indistinguishable from a mis-tap,
// a lost keystroke, or a save that worked. On the one screen whose job is to
// repair an unreachable engine, "nothing happened and nothing was said" is
// the failure mode that leaves a user re-typing the same rejected value.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
await openSettings(dom);
const select = settingField(dom, "Engine");
assert.ok(select, "the engine choice must be a select");
select.value = "not-a-real-engine";
dom.change(select as never);
await settle();
// Still refused, and still snapped back -- the existing guarantees.
assert.equal(app?.settings.get("engineId"), ENGINE_REMOTE_HTTP, "a rejected choice must not persist");
assert.equal(select.value, ENGINE_REMOTE_HTTP, "the field must reset to what is stored");
// And now SAID. Visibly, for a sighted user...
const shown = dom.find((n) => n.className.includes("setting-error") && n.hidden === false);
assert.ok(shown, `a refused setting was dropped silently:\n${dom.text()}`);
assert.match(shown.textContent, /Engine/, "the message must name the setting it refused");
// ...and out loud, because the field it belongs to may be off screen and a
// screen-reader user gets no "it snapped back" cue at all.
const spoken = dom.find((n) => n.getAttribute("aria-live") === "assertive");
assert.match(spoken?.textContent ?? "", /Engine/, "a refused setting must also be announced");
app?.dispose();
});
test("an ACCEPTED setting clears its OWN refusal, and only its own", async () => {
// Two pairs in one, because each guards the other's cheap implementation. A
// screen that reports every write as refused would satisfy the test above
// while making the setting look permanently broken, so an accepted write has
// to clear its message. And clearing ALL of them on any accepted write would
// wipe a refusal the user has not read off a DIFFERENT setting -- the note
// beside `engineId` is still true while `baseUrl` is being edited.
const { dom, platform } = harness();
const app = await mount({ root: dom.root as never, platform, now: () => 1, newChatId: () => "c1" });
const errorFor = (key: string) =>
dom.find((n) => n.dataset["settingError"] === key && n.hidden === false);
await openSettings(dom);
const select = settingField(dom, "Engine");
assert.ok(select);
select.value = "not-a-real-engine";
dom.change(select as never);
await settle();
assert.ok(errorFor("engineId"), "the refusal this test clears must be on screen first");
// An accepted write to a DIFFERENT setting leaves it alone.
const field = settingField(dom, "Engine address");
assert.ok(field);
field.value = "http://10.0.0.7:9000";
dom.change(field as never);
await settle();
assert.equal(app?.settings.get("baseUrl"), "http://10.0.0.7:9000", "the accepted edit must persist");
assert.equal(errorFor("baseUrl"), null, "an accepted write must not accuse itself");
assert.ok(errorFor("engineId"), "a refusal must not be cleared by a write to another setting");
// An accepted write to the SAME setting does clear it.
select.value = ENGINE_REMOTE_HTTP;
dom.change(select as never);
await settle();
assert.equal(app?.settings.get("engineId"), ENGINE_REMOTE_HTTP);
assert.equal(
errorFor("engineId"),
null,
`an accepted write must leave no refusal on its own field:\n${dom.text()}`,
);
app?.dispose();
});
+768 -18
View File
@@ -15,21 +15,56 @@
*/
import { createApp, type App } from "./boot.ts";
import { detectPlatform, type Platform } from "./platform.ts";
import { enginesFor, SETTING_DEFS } from "./registry.ts";
import { enginesFor, ENGINE_REMOTE_HTTP, SETTING_DEFS } from "./registry.ts";
import { intentFrom, type Actionable, type Intent } from "./intents.ts";
import { applyOps, emptyNodes, type RowNodes } from "./dom.ts";
import { installCrashHandler } from "./crash.ts";
import { emptyRender, reconcile, type RenderState } from "../../ui/src/render/reconcile.ts";
import { buildTranscript } from "../../ui/src/transcript/view-model.ts";
import { buildTranscript, type Row } from "../../ui/src/transcript/view-model.ts";
import type { RunView } from "../../core/src/run/controller.ts";
import type { Route } from "../../ui/src/router.ts";
import { en, type Strings } from "../../ui/src/i18n/strings.ts";
import { announce, initialAnnouncer, type AnnouncerState } from "../../ui/src/a11y/announce.ts";
import { createScreens } from "./mount.ts";
import { transition, screenFor, type ScreenId } from "../../ui/src/screens.ts";
import { routeTitle, chatRowName } from "../../ui/src/a11y/names.ts";
import {
focusForRoute,
headingId,
screenAnnouncement,
type FocusTarget,
type Navigation,
} from "../../ui/src/a11y/focus.ts";
import { buildSettings, labelOf, validateInput, type ValueRow } from "../../ui/src/settings/view-model.ts";
import { buildChatList } from "../../ui/src/chats/list-view-model.ts";
import type { ChatSummary, StoredMessage } from "../../core/src/chat/repository.ts";
import { createBundle } from "../../ui/src/i18n/bundle.ts";
import { resolveLocale, logicalInsets } from "../../ui/src/i18n/locale.ts";
import { geometryOf, initialLayout, withInsets, withKeyboard } from "../../ui/src/layout/insets.ts";
import {
emptyComposer,
setDraft,
submit as submitComposer,
keyAction,
heightFor,
lineCountOf,
draftOf,
type ComposerState,
} from "../../ui/src/composer/composer.ts";
import {
initialFollow,
onContentChanged,
onScroll,
jumpToLatest,
shouldShowJumpToLatest,
type FollowState,
type ScrollMetrics,
} from "../../ui/src/transcript/scroll.ts";
import type { EngineChoice } from "./engines.ts";
import type { AgentEnginePort } from "../../core/src/ports/agent-engine.ts";
import { createPraisonTsEngine, type RunPersistence } from "../../engines/src/praisonai-ts/engine.ts";
import type { PraisonAgent } from "../../engines/src/praisonai-ts/agent-api.ts";
import type { SettingsFacade } from "../../core/src/settings/store.ts";
import type { SettingDef, SettingsFacade } from "../../core/src/settings/store.ts";
import type { IgnoredReason } from "../../protocol/src/decode.ts";
import type { HttpPort } from "../../core/src/ports/http.ts";
@@ -123,12 +158,263 @@ export function appEngines(deps: {
});
}
/**
* The engine to start with when settings name none.
*
* `remote-http` on every platform, for now, and the reason is a hard fact about
* this build rather than a preference: the webview ships only `dist/app.js`
* (build-webview.mjs bundles `app/src/main.ts` and copies nothing else), and
* the in-process engine reaches praisonai-ts through a RUNTIME-computed import
* that is deliberately outside that bundle (#4437). So on a real device the
* in-process engine's module is simply ABSENT -- its first turn rejects with
* "the in-process engine is unavailable in this build". Making it the device
* DEFAULT would therefore swap the old "not answering" warning (which at least
* names Settings as the fix) for a first prompt that fails with no recovery,
* and would do it on the exact path registry.ts keeps the engine OUT of the
* shipping picker to avoid ("a picker must not offer a choice that bricks the
* app"). Until #4437 makes praisonai-ts resolvable inside the webview, the
* honest first-launch default stays the remote engine.
*
* A second reason the ternary this replaced was wrong: `Platform["kind"]` is
* only `"tauri" | "web"`, and DESKTOP Tauri (`cargo tauri dev`) reports
* `"tauri"` too -- so keying the in-process engine off `kind === "tauri"` also
* flipped the desktop/dev flow away from the remote engine it exists for, with
* no way here to tell a phone from a laptop.
*
* Kept as an exported function, taking the platform kind, so the seam is ready
* for the day a device can be told apart AND the engine ships -- and so a test
* drives it directly rather than asserting an expression appears in `mount`.
* The persisted `engineId` still wins over this (see `chosenStringOr` in
* boot.ts), so it only ever decides the very first launch.
*/
export function defaultEngineIdFor(_kind: Platform["kind"]): string {
return ENGINE_REMOTE_HTTP;
}
export interface MountDeps {
readonly root: HTMLElement;
readonly platform?: Platform;
readonly strings?: Strings;
readonly now?: () => number;
readonly newChatId?: () => string;
/** The locales the user prefers, most-preferred first. Injected so a test is
* deterministic; defaults to the host's `navigator.languages`. */
readonly locales?: readonly string[];
}
/**
* The locale to lay the app out in.
*
* `main.ts` used to pass `locale: "en"` as a literal, so every RTL user got an
* LTR layout and the direction logic in ui/src/i18n/locale.ts was unreachable.
* The English table is the only translation that ships today, so `resolveLocale`
* matches the user's request against `["en"]` and falls back to English -- but
* `createBundle` still derives `direction` from the *requested* tag, so an
* Arabic device lays out right-to-left even while it reads English words. That
* is the honest state until more tables exist, and it is what makes the #4607
* fix reachable.
*/
export function requestedLocales(deps: MountDeps): readonly string[] {
if (deps.locales !== undefined) return deps.locales;
const nav = (globalThis as { navigator?: { languages?: readonly string[]; language?: string } })
.navigator;
if (nav === undefined) return ["en"];
if (Array.isArray(nav.languages) && nav.languages.length > 0) return nav.languages;
return [nav.language ?? "en"];
}
/** A screen's focusable heading, carrying `tabindex="-1"` so focus.ts can move
* focus to it on a route change (a heading is not focusable otherwise). */
function screenHeading(doc: Document, route: Route, strings: Strings): HTMLElement {
const heading = doc.createElement("h2");
heading.className = "screen-heading";
heading.setAttribute("tabindex", "-1");
heading.dataset["focusId"] = headingId(route);
heading.textContent = routeTitle(strings, route);
return heading;
}
/**
* The editable control for one value row. MARKUP ONLY -- no listener.
*
* A `choice` renders a `<select>`, everything else a `<textarea>`-free
* `<input>`, and the kind comes from `row.control` (the def), never from the
* value: view-model.ts rule 4 -- one bad write to a hand-edited settings file
* would otherwise turn a picker into a text box and the setting could never be
* changed back.
*
* The control carries `data-action="set-setting"` and its key, and does
* nothing else. Committing it used to happen inside a `change` listener
* attached right here, which made settings the ONE affordance in the app that
* did not go through `intents.ts` -- so the decision (which key, what was
* typed, and whether a refusal is worth saying anything about) could only be
* reached by synthesising events against a DOM, and the refusal path ended in
* a field that silently snapped back. Delegated on root like every tap, the
* decision is `intentFrom`'s and the write is `perform`'s, next to the live
* region that has to announce it.
*
* This is the recovery path the remote-http default depends on: a phone
* reaches no `127.0.0.1:8765`, and `baseUrl` could be READ on the settings
* screen but not CHANGED, so a first launch that could not reach the engine
* had no way to point it at one.
*/
function settingControl(doc: Document, def: SettingDef, row: ValueRow, settings: SettingsFacade): HTMLElement {
let control: HTMLElement & { value: string };
if (row.control === "choice" && row.choices !== null) {
const select = doc.createElement("select") as HTMLElement & { value: string };
for (const choice of row.choices) {
const option = doc.createElement("option") as HTMLElement & { value: string };
option.value = String(choice);
option.textContent = String(choice);
select.append(option);
}
control = select;
} else {
const input = doc.createElement("input") as HTMLElement & { value: string; type: string };
input.type = row.control === "number" ? "number" : "text";
control = input;
}
// From the STORE, not from `row.value`: identical today, and it stays
// identical only while nothing repaints a row from a stale view.
control.value = String(settings.get(def.key) ?? def.default);
control.className = "setting-value setting-input";
control.setAttribute("aria-label", row.label);
// `change`, not `input`: a field commits on blur or Enter, so a half-typed
// address is never stored and `set` is not called once per keystroke.
// Delegated on root -- `change` bubbles.
control.dataset["action"] = "set-setting";
control.dataset["settingKey"] = def.key;
return control;
}
/**
* Where a refusal for one setting is written, and nothing until there is one.
*
* `role="alert"` and empty: an alert region that is created at the moment of
* the failure is announced unreliably, so it exists from first paint and only
* its text changes. `hidden` while empty, because an empty bordered box beside
* a field reads as a rendering fault.
*/
function settingError(doc: Document, key: string): HTMLElement {
const note = doc.createElement("p");
note.className = "setting-error";
note.dataset["settingError"] = key;
note.setAttribute("role", "alert");
note.hidden = true;
return note;
}
/**
* The settings screen, built from the live registry via `buildSettings`.
*
* Data-driven, so a setting added to the registry appears here without a code
* change -- the whole reason settings/view-model.ts renders from `facade.defs()`
* rather than a hard-coded list. `value` rows are EDITABLE: a phone has no
* engine to reach at `127.0.0.1:8765`, and the only recovery for the remote
* default is to change `baseUrl` here -- which was impossible while every row
* rendered as a read-only `<span>`. Secret rows stay presence-only (the facade
* has no getter for a secret, by design).
*/
export function buildSettingsScreen(
doc: Document,
settings: SettingsFacade,
strings: Strings,
): HTMLElement {
const section = doc.createElement("section");
section.className = "screen screen-settings";
section.append(screenHeading(doc, { name: "settings" }, strings));
const defByKey = new Map(settings.defs().map((def) => [def.key, def]));
const view = buildSettings(settings);
for (const warning of view.warnings) {
const note = doc.createElement("p");
note.className = "row row-notice";
note.dataset["tone"] = "warning";
note.textContent = warning.text;
section.append(note);
}
for (const group of view.sections) {
const title = doc.createElement("h3");
title.className = "settings-section";
title.textContent = group.title;
section.append(title);
for (const row of group.rows) {
const el = doc.createElement("div");
el.className = `row row-setting row-setting-${row.kind}`;
el.dataset["settingKey"] = row.key;
const label = doc.createElement("span");
label.className = "setting-label";
label.textContent = row.label;
el.append(label);
const def = row.kind === "value" ? defByKey.get(row.key) : undefined;
if (row.kind === "value" && def !== undefined) {
// The field and the place its refusal is written, together. The alert
// node ships empty and hidden rather than being created on the failure
// -- an alert region inserted at the moment it has something to say is
// announced unreliably by every screen reader.
el.append(settingControl(doc, def, row, settings), settingError(doc, row.key));
} else {
const value = doc.createElement("span");
value.className = "setting-value";
value.textContent = row.kind === "secret" ? row.presence : String(row.value);
el.append(value);
}
section.append(el);
}
}
return section;
}
/**
* The chat list, built from `session.list()` and `listUnreadable()`.
*
* Both lists, deliberately: a chat that failed to parse is a row here rather
* than a conversation that silently vanished -- the promise list-view-model.ts
* and repository.ts make together, and which only a renderer of the second list
* can keep.
*/
export function buildChatsScreen(
doc: Document,
summaries: readonly ChatSummary[],
unreadableIds: readonly string[],
nowMs: number,
strings: Strings,
): HTMLElement {
const section = doc.createElement("section");
section.className = "screen screen-chats";
section.append(screenHeading(doc, { name: "chats" }, strings));
const view = buildChatList(summaries, unreadableIds, nowMs);
if (view.state === "none") {
const empty = doc.createElement("p");
empty.className = "empty";
empty.textContent = strings.chatsEmpty;
section.append(empty);
return section;
}
if (view.state === "all-unreadable") {
const note = doc.createElement("p");
note.className = "row row-notice";
note.dataset["tone"] = "warning";
note.textContent = strings.chatsAllUnreadable(view.unreadableCount);
section.append(note);
}
for (const row of view.rows) {
const el = doc.createElement("button");
el.type = "button";
el.className = `row row-chat row-chat-${row.kind}`;
// A tap on an unreadable row has nowhere useful to go, so only real chats
// carry the open-chat intent -- intents.ts refuses a missing chatId anyway.
if (row.kind === "chat") {
el.dataset["action"] = "open-chat";
el.dataset["chatId"] = row.id;
}
el.setAttribute("aria-label", chatRowName(strings, row));
el.textContent = row.title;
section.append(el);
}
return section;
}
/** A crash-screen renderer. Text only, and no dependency on anything that may
@@ -159,6 +445,28 @@ export function stopNotice(stopped: boolean, strings: Strings): string | null {
return stopped ? null : strings.stopRefused;
}
/**
* A reopened conversation's stored messages, as reconciler rows.
*
* Built as real `Row`s -- not raw `<p>` nodes -- so the transcript that history
* paints into is the SAME render state the next turn's stream appends to. The
* defect this replaces reset `render` to empty and appended untracked nodes, so
* the next turn reconciled from nothing and inserted its rows above the history.
*
* Ids carry a `history:` prefix and the message index, so they are stable
* (a re-open paints the same rows, not duplicates) and cannot collide with a
* live turn's `text:N` ids -- a collision would make the first streamed
* paragraph update a history row in place instead of appending after it.
*/
export function historyRows(messages: readonly StoredMessage[]): readonly Row[] {
return messages.map((message, index) => ({
kind: "text",
id: `history:${index}:${message.role}`,
text: message.content,
streaming: false,
}));
}
/** `createApp`, with a thrown failure turned into the same typed result the
* anticipated failures already produce. */
async function bootOrFail(
@@ -193,13 +501,36 @@ export async function mount(deps: MountDeps): Promise<App | null> {
const platform = deps.platform ?? detectPlatform();
// ---- locale and direction ----------------------------------------------
// Detected, not the literal "en" this used to hardcode. The English table is
// the only one that ships, so `resolveLocale` falls back to it -- but the
// bundle's direction comes from the REQUESTED tag, so an Arabic device lays
// out right-to-left, which is what makes the #4607 direction fix reachable.
const requested = requestedLocales(deps);
// The tag the string table is for: matched against what actually ships, so an
// "en-GB" request lands on "en" instead of a blank screen.
const activeLocale = resolveLocale(requested, ["en"], "en");
// Direction from the tag the USER asked for, not the resolved one: an Arabic
// device reading English words still lays out right-to-left.
const bundle = createBundle(requested[0] ?? "en", {}, "silent");
root.setAttribute("dir", bundle.direction);
// ---- the frame the app paints into -------------------------------------
const screen = doc.createElement("div");
screen.className = "screen";
screen.dataset["screen"] = "chat";
const bar = doc.createElement("header");
bar.className = "topbar";
const title = doc.createElement("h1");
title.className = "screen-heading";
title.setAttribute("tabindex", "-1");
title.dataset["focusId"] = headingId({ name: "chat", chatId: "" });
const toChats = doc.createElement("button");
toChats.type = "button";
toChats.dataset["action"] = "navigate";
toChats.dataset["route"] = "chats";
toChats.textContent = strings.routeChats;
title.textContent = strings.appName;
const newChat = doc.createElement("button");
newChat.type = "button";
@@ -210,7 +541,7 @@ export async function mount(deps: MountDeps): Promise<App | null> {
toSettings.dataset["action"] = "navigate";
toSettings.dataset["route"] = "settings";
toSettings.textContent = strings.routeSettings;
bar.append(title, newChat, toSettings);
bar.append(title, toChats, newChat, toSettings);
const transcript = doc.createElement("main");
transcript.className = "transcript";
@@ -247,7 +578,16 @@ export async function mount(deps: MountDeps): Promise<App | null> {
sendButton.textContent = strings.actionSend;
composer.append(input, sendButton);
screen.append(bar, transcript, composer, polite, assertive);
// Shown only when the user has scrolled up off the bottom of a streaming
// transcript -- `scroll.ts` owns that decision, this is its affordance.
const jumpLatest = doc.createElement("button");
jumpLatest.type = "button";
jumpLatest.className = "jump-latest";
jumpLatest.dataset["action"] = "jump-latest";
jumpLatest.textContent = strings.streaming;
jumpLatest.hidden = true;
screen.append(bar, transcript, jumpLatest, composer, polite, assertive);
root.textContent = "";
root.append(screen);
@@ -256,9 +596,45 @@ export async function mount(deps: MountDeps): Promise<App | null> {
const nodes: RowNodes = emptyNodes();
let announcer: AnnouncerState = initialAnnouncer;
// A reopened conversation's stored messages, as reconciler rows. The
// controller only ever publishes the CURRENT turn -- it resets to
// `initialTurn` on each run -- so history is not in the RunView. It is held
// here and PREPENDED to every reconcile, so a follow-up turn's rows land
// below it and a reconcile never emits `remove` for history it did not know
// about. Empty for a fresh chat; cleared on New chat.
let history: readonly Row[] = [];
// ---- composer state (draft, key policy, autosize) ----------------------
// The composer is data now, not just a <textarea>: a draft that survives a
// trip to settings, an Enter-vs-Shift-Enter policy, and a clamped height. The
// field mirrors this state; this state is the source of truth.
let composerState: ComposerState = emptyComposer();
const syncComposer = (): void => {
const text = draftOf(composerState);
if (input.value !== text) input.value = text;
input.style.setProperty("height", `${heightFor(lineCountOf(text))}px`);
sendButton.disabled = text.trim() === "";
};
// ---- follow-the-stream (scroll.ts) -------------------------------------
let follow: FollowState = initialFollow;
const metricsOf = (): ScrollMetrics => ({
scrollTop: transcript.scrollTop,
scrollHeight: transcript.scrollHeight,
clientHeight: transcript.clientHeight,
});
const applyFollow = (): void => {
jumpLatest.hidden = !shouldShowJumpToLatest(follow);
};
const publish = (view: RunView): void => {
const built = buildTranscript(view.turn, view.approvals);
const diff = reconcile(render, built.rows);
// History first, then the live turn. `history` is empty for a fresh chat,
// so this is a no-op there; for a reopened chat it keeps the restored
// conversation ABOVE the turn now streaming and inside the render state, so
// the diff updates the live rows without removing the history.
const rows = history.length === 0 ? built.rows : [...history, ...built.rows];
const diff = reconcile(render, rows);
applyOps(transcript, nodes, diff.ops, strings);
render = diff.next;
@@ -267,7 +643,7 @@ export async function mount(deps: MountDeps): Promise<App | null> {
const spoken = announce(announcer, {
turn: view.turn,
strings,
locale: "en",
locale: activeLocale,
nowMs: Date.now(),
});
announcer = spoken.state;
@@ -286,8 +662,19 @@ export async function mount(deps: MountDeps): Promise<App | null> {
if (polites.length > 0) polite.textContent = polites.map((a) => a.text).join(" ");
if (assertives.length > 0) assertive.textContent = assertives.map((a) => a.text).join(" ");
sendButton.textContent = view.turn.phase === "streaming" ? strings.actionStop : strings.actionSend;
sendButton.dataset["action"] = view.turn.phase === "streaming" ? "stop" : "send";
const streaming = view.turn.phase === "streaming";
sendButton.textContent = streaming ? strings.actionStop : strings.actionSend;
sendButton.dataset["action"] = streaming ? "stop" : "send";
// Stop is always tappable; Send is disabled on an empty draft. Guarding the
// disable on the action keeps a streaming Stop from going dead because the
// draft happens to be empty.
sendButton.disabled = streaming ? false : draftOf(composerState).trim() === "";
// New content asks to be scrolled to; whether it IS depends on scroll.ts.
const outcome = onContentChanged(follow, metricsOf());
follow = outcome.state;
if (outcome.action.kind === "scrollTo") transcript.scrollTop = outcome.action.top;
applyFollow();
};
// `createApp` returns a typed BootResult for the failures it anticipated --
@@ -316,9 +703,12 @@ export async function mount(deps: MountDeps): Promise<App | null> {
engines: (persistence, settings, onIgnored) =>
appEngines({ settings, http: platform.http, persistence, onIgnored }),
settingDefs: SETTING_DEFS,
// The default when settings name none. createApp prefers the persisted
// `engineId` over this; passing it as a literal was ignoring the setting.
engineId: "remote-http",
// The default when settings name none, chosen by platform: the in-process
// engine on a device (a phone has no `127.0.0.1:8765` to reach, and a
// cleartext localhost address is refused by iOS ATS and Android), the
// remote engine on desktop/dev where one actually runs. createApp prefers
// the persisted `engineId` over this, so it only decides the first launch.
engineId: defaultEngineIdFor(platform.kind),
onPublish: publish,
now: deps.now ?? (() => Date.now()),
newChatId: mintChatId,
@@ -328,31 +718,219 @@ export async function mount(deps: MountDeps): Promise<App | null> {
// A named failure, on screen. `app/src/engines.ts` promises exactly this
// and nothing rendered it: an unusable engine used to be an unhandled
// branch that left a blank page.
//
// Reached now only by a PERMANENT failure -- storage that will not open, a
// protocol mismatch. An engine that is merely unreachable boots and warns
// instead; see below.
renderFatal(root, strings.bootFailed(booted.detail));
return null;
}
const app = booted.app;
// The engine is not answering, but the app is usable. Said out loud rather
// than left for the first message to discover -- and assertive, because the
// user is about to type into something that cannot reply yet.
//
// This branch exists because refusing to boot on an unreachable engine left
// the user on an error screen with no way back: they cannot open Settings to
// change the address, because Settings is where the address is changed.
if (booted.notReady !== undefined) {
const warning = doc.createElement("p");
warning.className = "row row-notice";
warning.dataset["tone"] = "warning";
warning.textContent = strings.engineNotReady(booted.notReady.detail);
transcript.append(warning);
assertive.textContent = strings.engineNotReady(booted.notReady.detail);
}
// ---- the shell drives layout -------------------------------------------
// Derived through `geometryOf`, not inline px. The pure derivation is what
// makes the keyboard cover the home indicator (`max`, never `+`) and what
// turns a WebView's mid-rotation NaN into 0 rather than a dropped style
// declaration that drops the composer under the keyboard. The physical
// left/right edges then become logical padding, so RTL puts the safe-area on
// the leading edge.
const applyGeometry = (): void => {
const insets = platform.shell.insets;
screen.style.setProperty("--keyboard-height", `${platform.shell.keyboardHeightPx}px`);
screen.style.setProperty("--inset-top", `${insets.top}px`);
let layout = initialLayout(platform.shell.insets);
layout = withInsets(layout, platform.shell.insets);
layout = withKeyboard(layout, platform.shell.keyboardHeightPx);
const geometry = geometryOf(layout);
screen.style.setProperty("--keyboard-height", `${geometry.composerBottomPx}px`);
screen.style.setProperty("--inset-top", `${geometry.scrollTopPx}px`);
const logical = logicalInsets(bundle.direction, geometry.composerLeftPx, geometry.composerRightPx);
// The GUTTER is added here, not left to the stylesheet. An inline style
// beats `app.css`'s `calc(var(--safe-area-inset-left) + .75rem)`, so
// writing the bare inset put the composer flush against the screen edge
// while the topbar and transcript kept their gutter -- measured at a 320px
// viewport with no side insets: the textarea's left edge sat at x = 0.
composer.style.setProperty("padding-inline-start", `calc(${logical.startPx}px + .75rem)`);
composer.style.setProperty("padding-inline-end", `calc(${logical.endPx}px + .75rem)`);
};
applyGeometry();
platform.shell.onInsetsChanged(applyGeometry);
platform.shell.onKeyboardHeightChanged(applyGeometry);
// ---- screens the router drives -----------------------------------------
// The chat screen is retained (it holds scroll position and a live stream);
// settings and chats are built on demand and rebuilt each visit. `transition`
// decides what to mount, hide and destroy; this half only obeys it.
const screens = createScreens({
root,
build: (id: ScreenId): HTMLElement => {
if (id === "settings") return buildSettingsScreen(doc, app.settings, strings);
if (id === "chats") {
// A fresh snapshot each visit: a chat created since the list was last
// seen must appear, and one deleted must be gone.
const section = buildChatsScreen(doc, [], [], deps.now?.() ?? Date.now(), strings);
void (async (): Promise<void> => {
const [summaries, unreadable] = await Promise.all([
app.session.list(),
app.session.repository.listUnreadable(),
]);
const fresh = buildChatsScreen(
doc,
summaries as readonly ChatSummary[],
unreadable,
deps.now?.() ?? Date.now(),
strings,
);
section.textContent = "";
for (const child of [...fresh.children]) section.append(child as HTMLElement);
})().catch(() => {
// Storage can reject while the list loads -- SecurityError with site
// data blocked, QuotaExceededError. A floating rejection here reaches
// the global crash handler and replaces the WHOLE app with the fatal
// screen; a failed chat list must stay a LOCAL failure, so the user
// can go back and keep using the conversation they are in.
section.textContent = "";
const notice = doc.createElement("p");
notice.className = "row row-notice";
notice.dataset["tone"] = "warning";
notice.setAttribute("role", "alert");
notice.textContent = strings.crashed;
section.append(notice);
});
return section;
}
// "about" and "chat" have no builder here: chat is the pre-built root
// screen, and about is not yet a route the app pushes.
return doc.createElement("section");
},
});
// The chat screen already exists; register it so `transition` treats it as
// live and retains it rather than trying to build a second one.
screens.nodes.set("chat", screen);
// The element by its `data-focus-id`, searched under root. There is no
// `querySelector` in the seam a test drives, and there does not need to be:
// the set of focusable ids is tiny (a heading per live screen), so a walk is
// both correct and cheap.
const byFocusId = (id: string): HTMLElement | null => {
if (id === "") return null;
const stack: HTMLElement[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (node === undefined) break;
if (node.dataset["focusId"] === id) return node;
for (const child of node.children) {
if (child instanceof HTMLElement) stack.push(child);
}
}
return null;
};
// Whatever the renderer must return to when a Back gesture pops a screen. A
// pop restores focus to the control that opened the screen -- the chat row,
// the settings button -- so the user lands where they were, not at the top of
// a list they have to scroll down again.
let restoreFocus: HTMLElement | null = null;
// The three lines focus.ts deliberately does NOT do -- move focus, save it,
// restore it -- because they are the only part it cannot unit test. Doing
// less than this leaves the decision computed and discarded, which is the
// very "focus falls to <body>" bug the whole module exists to prevent.
const applyFocus = (target: FocusTarget): void => {
switch (target.kind) {
case "none":
return;
case "element": {
byFocusId(target.id)?.focus();
return;
}
case "restore": {
// The saved element may be gone -- popping back after deleting the chat
// you were viewing means the row you came from no longer exists -- so
// fall back to the destination's heading rather than focusing nothing.
const saved = restoreFocus;
if (saved !== null && saved.isConnected) saved.focus();
else byFocusId(target.fallbackId)?.focus();
restoreFocus = null;
return;
}
}
};
let currentRoute: Route | null = null;
const showRoute = (route: Route, nav: Navigation): void => {
const change = transition(currentRoute, route, screens.live());
if (!change.noop) screens.apply(change);
const focus = focusForRoute(currentRoute, route, nav);
// Announce the screen change, so a route change is never silent to a
// screen reader even when focus lands somewhere with a short name.
if (focus.kind === "element" && focus.id !== "") {
assertive.textContent = screenAnnouncement(strings, route);
}
// Then actually move focus. Computing the target and never applying it left
// focus on the screen the user just left, or on <body> once that screen was
// hidden -- exactly the failure focus.ts's ids exist to fix.
applyFocus(focus);
currentRoute = route;
};
// The router's root is `chats`, but the app opens on the chat screen; align
// the two so the first back gesture behaves and `screenFor` agrees.
currentRoute = { name: "chat", chatId: "" };
screen.hidden = false;
// The previous stack depth, to tell a push from a pop: a shorter stack is a
// Back, a longer one a forward navigation. The replace below seeds it at 1.
let previousDepth = 1;
app.router.subscribe((stack) => {
const top = stack[stack.length - 1];
if (top === undefined) return;
const nav: Navigation =
stack.length < previousDepth ? "pop" : stack.length > previousDepth ? "push" : "replace";
// Save the control the user is leaving from BEFORE the DOM changes, so a
// later pop can return to it. Only on a push -- a pop consumes the saved
// target, and a replace is not a place to come back to.
if (nav === "push") {
const active = doc.activeElement;
restoreFocus = active instanceof HTMLElement ? active : null;
}
previousDepth = stack.length;
showRoute(top, nav);
});
// Replace the router's `chats` root with the chat the app actually opens on,
// so pushing `chats` later is a real navigation and not swallowed as a push
// of the route already on top.
app.router.replace({ name: "chat", chatId: "" });
// ---- taps ---------------------------------------------------------------
/** The tags that HOLD a value. Checked by tag rather than by reading
* `node.value` on everything, because `Actionable.value` being ABSENT is
* what tells intents.ts "this element is not a field" -- and a `<div>` in
* the fake DOM reports `""`, which would read as a cleared setting. */
const FIELD_TAGS = new Set(["INPUT", "SELECT", "TEXTAREA"]);
const chainOf = (target: EventTarget | null): Actionable[] => {
const chain: Actionable[] = [];
let node = target instanceof Element ? target : null;
while (node !== null) {
if (node instanceof HTMLElement) {
const value = (node as HTMLElement & { value?: unknown }).value;
chain.push({
dataset: { ...node.dataset },
...(node instanceof HTMLButtonElement ? { disabled: node.disabled } : {}),
...(FIELD_TAGS.has(node.tagName) && typeof value === "string" ? { value } : {}),
});
}
node = node.parentElement;
@@ -360,6 +938,47 @@ export async function mount(deps: MountDeps): Promise<App | null> {
return chain;
};
/**
* Put every settings field back in step with the STORE, and show or clear
* the refusal for one key.
*
* A walk rather than a captured element, for two reasons. The settings
* screen is rebuilt on every visit (`screens.build`), so any reference held
* across a navigation points at a detached node. And a `set` the store
* ACCEPTS can still store something other than what was typed -- a def's
* `validate` may clamp -- so the honest thing after any write, accepted or
* refused, is to redraw the fields from what is actually stored rather than
* to leave the typed text on screen.
*/
const syncSettings = (key: string, refusal: string | null): void => {
const defs = new Map(app.settings.defs().map((d) => [d.key, d]));
const stack: HTMLElement[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.children) {
if (child instanceof HTMLElement) stack.push(child);
}
if (node.dataset["action"] === "set-setting") {
const def = defs.get(node.dataset["settingKey"] ?? "");
if (def !== undefined) {
(node as HTMLElement & { value: string }).value = String(
app.settings.get(def.key) ?? def.default,
);
}
continue;
}
const errorFor = node.dataset["settingError"];
if (errorFor === undefined) continue;
// Only this key's note is touched. Clearing them all would wipe a
// refusal the user has not read off a DIFFERENT setting; leaving them
// all would keep accusing a write that has since succeeded.
if (errorFor !== key) continue;
node.textContent = refusal ?? "";
node.hidden = refusal === null;
}
};
const perform = async (intent: Intent): Promise<void> => {
switch (intent.kind) {
case "send":
@@ -385,6 +1004,8 @@ export async function mount(deps: MountDeps): Promise<App | null> {
// the same id".
app.controller.setChat(mintChatId());
app.session.reset();
// A fresh chat has no history to keep above the next turn.
history = [];
render = emptyRender;
nodes.nodes.clear();
announcer = initialAnnouncer;
@@ -401,27 +1022,156 @@ export async function mount(deps: MountDeps): Promise<App | null> {
case "navigate":
app.router.push({ name: intent.route } as Route);
return;
case "set-setting": {
// The def is looked up from the LIVE facade, not from `SETTING_DEFS`,
// so this cannot drift from what the screen was rendered off. An
// unknown key means the DOM and the registry disagree -- refuse it
// rather than write it: `set` would refuse anyway, silently.
const def = app.settings.defs().find((d) => d.key === intent.key);
if (def === undefined) return;
// `validateInput` first, so an unparseable value never reaches `set`
// where the refusal comes back as a bare `false` with no reason.
const validated = validateInput(def, intent.raw);
let stored = false;
if (validated !== null) {
try {
stored = await app.settings.set(intent.key, validated);
} catch {
// `set` persists through StoragePort, which REJECTS on a real
// device (SecurityError with site data blocked, QuotaExceededError
// under storage pressure). Left to float, that rejection reaches
// the global crash handler and replaces the WHOLE app with the
// fatal screen. A failed settings write must stay local -- and it
// is a refusal like any other, so it is said rather than swallowed.
stored = false;
}
}
// Said, not merely undone. A field that snaps back in silence is
// indistinguishable from a mis-tap or from a save that worked, and on
// this screen that leaves someone re-typing the same refused value.
const refusal = stored ? null : strings.settingRejected(labelOf(def));
if (refusal !== null) assertive.textContent = refusal;
syncSettings(intent.key, refusal);
return;
}
case "open-chat": {
// Reopen a previous conversation. `session.list()` had no app caller and
// `session.open()` no way to be reached; this is the path from the chat
// list back into a stored transcript.
const opened = await app.session.open(intent.chatId);
if (!opened) return;
app.controller.setChat(intent.chatId);
render = emptyRender;
nodes.nodes.clear();
announcer = initialAnnouncer;
transcript.textContent = "";
polite.textContent = "";
assertive.textContent = "";
// Paint the stored messages THROUGH the reconciler, not as raw nodes.
//
// Appending untracked `<p>` elements and resetting `render` to empty was
// a defect: the next turn reconciled from an empty render state and
// `applyOps` inserted its rows at index 0 -- ABOVE the history -- while
// the manually appended messages stayed outside `render`/`nodes` and
// could never be updated. The newest turn rendered above the older
// conversation. Holding the history as real `Row`s and prepending it in
// `publish` keeps it in the same coordinate system the stream appends to
// AND makes it survive the next turn's reconcile.
const chat = app.session.current();
history = chat === null ? [] : historyRows(chat.messages);
const seeded = reconcile(render, history);
applyOps(transcript, nodes, seeded.ops, strings);
render = seeded.next;
app.router.push({ name: "chat", chatId: intent.chatId });
return;
}
default:
return;
}
};
const submit = async (): Promise<void> => {
// The field is the live edit; the composer state is the durable draft. Take
// from the field so a test that sets `input.value` directly still sends, and
// clear both -- `submitComposer` is what makes a double tap on send a no-op.
const text = input.value.trim();
if (text === "") return; // an empty send is a no-op, not an empty turn
const busy = sendButton.dataset["action"] === "stop";
composerState = setDraft(composerState, input.value);
const result = submitComposer(composerState, busy);
composerState = result.next;
input.value = "";
await app.controller.send(text);
syncComposer();
if (result.sent === null) return; // refused while a turn is in flight
await app.controller.send(result.sent);
};
// ---- composer field <-> state ------------------------------------------
input.addEventListener("input", () => {
composerState = setDraft(composerState, input.value);
input.style.setProperty("height", `${heightFor(lineCountOf(input.value))}px`);
if (sendButton.dataset["action"] !== "stop") {
sendButton.disabled = input.value.trim() === "";
}
});
input.addEventListener("keydown", (event) => {
const e = event as KeyboardEvent;
const action = keyAction({
key: e.key,
shiftKey: e.shiftKey,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
isComposing: e.isComposing,
});
if (action === "send") {
e.preventDefault();
void submit();
}
// "newline" and "ignore" both let the field handle the key normally.
});
syncComposer();
// ---- scroll follow ------------------------------------------------------
transcript.addEventListener("scroll", () => {
follow = onScroll(follow, metricsOf());
applyFollow();
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
void submit();
});
screen.addEventListener("click", (event) => {
// Delegated on ROOT, not the chat screen: the settings and chats screens are
// siblings of it, so a tap on a chat row would otherwise never be heard.
root.addEventListener("click", (event) => {
const target = event.target;
// Jump-to-latest is not an intent (it is a scroll decision, not an engine
// call), so it is handled here before the intent walk.
const chain = chainOf(target);
if (chain.some((el) => el.dataset["action"] === "jump-latest")) {
const outcome = jumpToLatest(follow, metricsOf());
follow = outcome.state;
if (outcome.action.kind === "scrollTo") transcript.scrollTop = outcome.action.top;
applyFollow();
(event as { preventDefault(): void }).preventDefault();
return;
}
const intent = intentFrom(chain);
if (intent === null) return;
(event as { preventDefault(): void }).preventDefault();
void perform(intent);
});
// Committing a field is delegated on ROOT for the same reason a tap is: the
// settings screen is rebuilt on every visit, so a listener attached to a
// field belongs to a node that is thrown away on the next navigation. No
// `preventDefault` -- `change` has no default action to cancel, and the
// composer's textarea carries no `data-action`, so `intentFrom` refuses it.
root.addEventListener("change", (event) => {
const intent = intentFrom(chainOf(event.target));
if (intent === null) return;
event.preventDefault();
void perform(intent);
});
@@ -336,3 +336,78 @@ test("every setting's default satisfies its own validator", () => {
);
}
});
// ---- the address the user CORRECTS has to be the address that is used -------
//
// `enginesFor` read `baseUrl` once, at construction, and both the engine and
// the health probe closed over that string. The app builds its engine exactly
// once, at boot, so a phone that could not reach `127.0.0.1:8765` kept sending
// there for the whole session no matter what was typed into Settings -- the
// setting persisted, the screen agreed, and only a relaunch changed anything.
// A recovery path that requires force-quitting the app is not a recovery path.
const A_RUN = {
prompt: "hello",
chatId: "c1",
runId: "r1",
tools: false,
regenerateOf: null,
attachments: [],
};
/** Run a turn to exhaustion, discarding the events. What is under test is the
* URL the transport was pointed at, not what came back. */
async function drain(engine: { run: (r: typeof A_RUN, s: AbortSignal) => AsyncIterable<unknown> }): Promise<void> {
for await (const _event of engine.run(A_RUN, new AbortController().signal)) {
// discarded on purpose
}
}
test("a turn goes to the address the user set AFTER the engine was built", async () => {
const { store, settings, persistence } = await build();
const http = createFakeHttp();
http.on("/chat", () => sseResponse(""));
// Built first, exactly as boot.ts does: the engine exists before the user
// ever reaches Settings.
const engine = await enginesFor({ settings, http, persistence })[0]!.create();
await store.set("baseUrl", "http://10.0.0.7:9000");
await drain(engine as never);
assert.equal(
http.sent.at(-1)?.url,
"http://10.0.0.7:9000/chat",
"the corrected address must take effect without a relaunch",
);
await engine.dispose();
});
test("an untouched setting still reaches the address the engine was built with", async () => {
// The pair. Reading the setting late must not mean reading it wrongly: with
// nothing changed, the default is still where a turn goes.
const { settings, persistence } = await build();
const http = createFakeHttp();
http.on("/chat", () => sseResponse(""));
const engine = await enginesFor({ settings, http, persistence })[0]!.create();
await drain(engine as never);
assert.equal(http.sent.at(-1)?.url, "http://127.0.0.1:8765/chat");
await engine.dispose();
});
test("a corrected address is re-read by the health probe too", async () => {
// The probe is what decides whether the engine is offered at all, and what
// produces the "not answering" warning the user is trying to clear. Probing
// the OLD address after the address was fixed reports a failure about a
// machine nobody is talking to any more.
const { store, settings, persistence } = await build();
const http = createFakeHttp();
http.on("/health", () => jsonResponse(200, { ok: true, version: PROTOCOL_VERSION }));
const choice = enginesFor({ settings, http, persistence }).find((c) => c.id === ENGINE_REMOTE_HTTP);
await store.set("baseUrl", "http://10.0.0.7:9000/");
await choice!.probe!();
assert.equal(http.sent.at(-1)?.url, "http://10.0.0.7:9000/health");
});
+22 -2
View File
@@ -116,7 +116,24 @@ function stringSetting(settings: SettingsFacade, key: string, fallback: string):
* is not here, which is the honest message in that case.
*/
export function enginesFor(deps: RegistryDeps): readonly EngineChoice[] {
const baseUrl = stringSetting(deps.settings, "baseUrl", "http://127.0.0.1:8765").replace(/\/+$/, "");
/**
* The engine address, read at the moment it is USED.
*
* This was a `const` read once, here, and both the engine and the probe
* closed over the string. The app builds its engine exactly once, at boot
* (boot.ts), and holds it for the session -- so a phone that could not reach
* `127.0.0.1:8765` kept sending there no matter what was typed into
* Settings. The value persisted, the screen agreed, and only a relaunch
* changed anything: a recovery path that requires force-quitting the app.
*
* Rebuilding the engine on the change was the alternative, and it is a much
* larger one: `createRunController` takes the engine at construction and
* there is no seam to swap it, so following the setting that way means
* rebuilding the controller mid-session and dropping whatever turn is in
* flight. A resolver costs one call per request and no signature above it.
*/
const baseUrl = (): string =>
stringSetting(deps.settings, "baseUrl", "http://127.0.0.1:8765").replace(/\/+$/, "");
const choices: EngineChoice[] = [
{
id: ENGINE_REMOTE_HTTP,
@@ -133,7 +150,10 @@ export function enginesFor(deps: RegistryDeps): readonly EngineChoice[] {
// status alone routes a chat into a broken engine and reports the
// nonsense back as a model failure. Run at selection, before the engine
// is offered -- the in-process engine has no remote and supplies none.
probe: () => probeHealth(deps.http, baseUrl),
// Called, not captured: the probe is what produces the "not answering"
// warning the user is trying to clear, so probing the address they just
// replaced reports a failure about a machine nobody is talking to.
probe: () => probeHealth(deps.http, baseUrl()),
},
];
@@ -249,6 +249,149 @@ test("the tick stops when the turn ends", async () => {
assert.equal(h.views.length, before, "a tick after the turn must publish nothing");
});
test("the flush tick paints through the publish gate, so backpressure bounds it", async () => {
// The gate (core/src/pacing/publish-gate.ts) exists to skip a paint when the
// renderer cannot keep up, and it carries two mobile-tuned constants. Before
// this it was unreachable in the shipping pipeline: the coalescer's flush tick
// drains every maxDelayMs, so `coalescer.push()` in the run loop almost always
// returned [] and the per-event `gate()` was never consulted, while the tick's
// own publish was ungated (issue #4639). A module with tuned constants that
// never runs reads as load-bearing to the next person who changes pacing.
//
// Now the tick publishes THROUGH the gate. This test drives many small deltas,
// each flushed by a tick, WITHOUT releasing an animation frame between them --
// so the gate closes after its first paint and stays closed until MAX_HELD_CHARS
// of new text has streamed. The paint count must therefore be far below the
// number of ticks. Reverting to an ungated tick publish makes every tick paint
// and fails the upper bound; the gate never being consulted at all fails it too.
const time = createFakeTime();
const views: RunView[] = [];
const DELTAS = 40;
const engine = {
id: "s",
protocolVersion: PROTOCOL_VERSION,
capabilities: {
streaming: true, reasoning: false, tools: true,
approvals: false, cancellation: true, attachments: false,
},
async *run() {
yield { type: "start", msgId: "m", runId: "r" } as RunEvent;
for (let i = 0; i < DELTAS; i++) {
// Ten chars a delta: well under MAX_HELD_CHARS (96), so several ticks
// must pass before the gate reopens on the character cap alone.
yield { type: "delta", msgId: "m", text: "0123456789" } as RunEvent;
// Flush this delta on the tick path -- and DO NOT release a frame, so
// the gate cannot reopen that way. A closed gate then skips the paint.
time.advance(20);
time.tick();
await Promise.resolve();
}
yield {
type: "end", msgId: "m", userIndex: 0, assistantIndex: 1, versions: 1, active: 0,
} as RunEvent;
},
async decide() { return false; },
async cancel() { return false; },
async dispose() {},
} as unknown as Parameters<typeof createRunController>[0]["engine"];
const controller = createRunController({
engine, time,
onPublish: (v) => views.push(v),
});
await controller.send("go");
// No token may be lost to pacing: the final answer is every delta.
assert.equal(views.at(-1)?.turn.text.length, DELTAS * 10, "no token may be lost to pacing");
// The point of the fix: paints are bounded by the gate, not one per tick. With
// 40 ticks and no frame release, only a handful of tick paints get through --
// the ones where MAX_HELD_CHARS of new text has accumulated since the last.
// (send() and the final finally-publish are ungated, hence the small slack.)
const tickCeiling = Math.ceil((DELTAS * 10) / 96) + 4;
assert.ok(
views.length <= tickCeiling,
`the gate must bound tick paints: got ${views.length} for ${DELTAS} ticks`,
);
assert.ok(views.length >= 2, "something must have been published");
});
test("text drained by a rejected tick is still painted when the stream pauses", async () => {
// The gate rejects a tick's paint under backpressure -- but the tick has
// ALREADY drained that text from the coalescer, so it lives only on the
// transcript, unpainted. If the stream then pauses (no further deltas), no
// later tick sees pending text and the gate reopening does not itself
// publish, so before the catch-up branch that progress stayed invisible
// until the next delta or the final finally-publish. On a phone a provider
// pausing mid-answer is ordinary, so "received but not shown" is a real
// stall, not a corner case.
//
// Here: one small delta (< MAX_HELD_CHARS) closes the gate on its first
// paint, a second small delta is drained by a tick the closed gate rejects,
// and then the stream goes quiet for many ticks. Advancing past
// UNPAINTED_REOPEN_MS reopens the gate; a catch-up tick must then paint the
// full text WHILE THE RUN IS STILL LIVE -- not only at the end.
const time = createFakeTime();
const views: RunView[] = [];
let releaseEnd: () => void = () => {};
const endHeld = new Promise<void>((r) => { releaseEnd = r; });
let paintedDuringPause = "";
const engine = {
id: "s",
protocolVersion: PROTOCOL_VERSION,
capabilities: {
streaming: true, reasoning: false, tools: true,
approvals: false, cancellation: true, attachments: false,
},
async *run() {
yield { type: "start", msgId: "m", runId: "r" } as RunEvent;
// First delta: paints on the tick, closing the gate.
yield { type: "delta", msgId: "m", text: "hello " } as RunEvent;
time.advance(20); time.tick(); await Promise.resolve();
// Second delta: drained by a tick the now-closed gate rejects, so it is
// on the transcript but unpainted.
yield { type: "delta", msgId: "m", text: "world" } as RunEvent;
time.advance(20); time.tick(); await Promise.resolve();
// The stream now PAUSES. The gate's reopen timer fires (as it would after
// UNPAINTED_REOPEN_MS on a real phone), reopening the gate; ticks keep
// firing. The catch-up branch must then paint the stranded "hello world"
// even though the coalescer is empty and no new delta has arrived.
for (const s of time.schedulers) s.fireTimer();
for (let i = 0; i < 20; i++) {
time.advance(20); time.tick(); await Promise.resolve();
}
// Snapshot what was on screen BEFORE the turn ends, so this cannot pass
// on the strength of the final finally-publish alone.
paintedDuringPause = views.at(-1)?.turn.text ?? "";
await endHeld;
yield {
type: "end", msgId: "m", userIndex: 0, assistantIndex: 1, versions: 1, active: 0,
} as RunEvent;
},
async decide() { return false; },
async cancel() { return false; },
async dispose() {},
} as unknown as Parameters<typeof createRunController>[0]["engine"];
const controller = createRunController({
engine, time,
onPublish: (v) => views.push(v),
});
const sent = controller.send("hi");
// Let the pause loop run, then release the end.
for (let i = 0; i < 60; i++) await Promise.resolve();
releaseEnd();
await sent;
assert.equal(
paintedDuringPause,
"hello world",
"text drained by a rejected tick must be painted once the gate reopens, not held until the turn ends",
);
assert.equal(views.at(-1)?.turn.text, "hello world", "and the final answer is complete");
});
// ---- an approval the engine refuses -----------------------------------------
test("an approval the engine refuses is shown as failed, never as sent", async () => {
@@ -193,6 +193,17 @@ export function createRunController(deps: ControllerDeps): RunController {
const coalescer = createCoalescer(maxBytes, maxDelayMs);
const gate = createPublishGate(deps.time.createScheduler());
let streamed = 0;
// A tick drains the coalescer BEFORE the gate is consulted, so a frame the
// gate rejects is already gone from the coalescer -- its text lives only on
// `turn`, applied by the run loop, and is not yet painted. If the stream
// then pauses, no later tick sees pending text and the gate reopening does
// not itself publish, so that progress would stay invisible until the next
// delta or the final publish. `paintedChars` records how far the screen has
// actually been painted; when a tick finds nothing new to drain but the gate
// has since reopened and `streamed` has moved past it, it flushes that
// stranded progress. This is the backpressure RELEASE the gate needs to be
// safe: it skips a paint under load, then catches up once the renderer can.
let paintedChars = 0;
// The coalescer's TIME bound, which was declared, documented and never
// connected -- TimePort.every's own comment calls it "the coalescer's
@@ -219,17 +230,50 @@ export function createRunController(deps: ControllerDeps): RunController {
const frame = coalescer.tick(deps.time.nowMs());
if (frame === null) {
// Nothing to paint from the coalescer, but refusals still need to
// reach the screen -- and to stop piling up in the sink.
if (hadDrops) publish();
// Nothing new to drain. Two reasons a paint may still be owed:
// - a refusal arrived and must reach the screen (and stop piling up);
// - a previous tick drained text but the gate rejected it, so that
// progress is on `turn` yet unpainted. Once the gate reopens, catch
// it up -- otherwise a stream that pauses right after a rejected tick
// leaves already-received text invisible until the next delta.
const stranded = streamed > paintedChars;
if (hadDrops || (stranded && gate(streamed))) {
paintedChars = streamed;
publish();
}
return;
}
applyFrame(frame);
// Deliberately NOT gated. The gate skips intermediate paints when the
// renderer cannot keep up; this frame exists precisely because nothing
// has painted for maxDelayMs, so skipping it reintroduces the stall it
// was added to prevent.
publish();
// Gated -- and this is where the gate actually earns its constants.
//
// It used to publish unconditionally, reasoning that a tick frame exists
// "precisely because nothing has painted for maxDelayMs, so skipping it
// reintroduces the stall". That reasoning had the gate's own state
// backwards. `gate(streamed)` returns TRUE whenever the gate is OPEN, and
// the gate is open exactly when nothing has painted recently: it opens on
// its first call, and reopens on a frame callback or after
// UNPAINTED_REOPEN_MS (200) with no paint. So the tick after a quiet
// period passes the gate and paints immediately -- the stall the old
// comment feared cannot happen.
//
// What the gate DOES skip is a tick that fires just after a paint, while
// the renderer is still catching up. That is backpressure, and it is the
// one thing this pipeline had no path for: with the coalescer's flush
// tick draining every 16ms, `coalescer.push()` below almost always
// returns [] so the per-event gate at line ~275 was never consulted, and
// MAX_HELD_CHARS / UNPAINTED_REOPEN_MS -- tuned for mobile -- bound
// nothing (issue #4639). Consulting the gate here makes it the single
// backpressure authority and both constants load-bearing, while
// MAX_HELD_CHARS still forces the tail out so a closed gate cannot swallow
// the end of an answer.
//
// When the gate rejects here, the drained text is left unpainted on
// `turn`; `paintedChars` stays behind `streamed` and a later tick catches
// it up once the gate reopens (see the frame === null branch above).
if (gate(streamed)) {
paintedChars = streamed;
publish();
}
});
const request: RunRequest = {
@@ -264,6 +308,9 @@ export function createRunController(deps: ControllerDeps): RunController {
for (const frame of coalescer.push({ kind: "structured", payload: event.type }, deps.time.nowMs())) {
applyFrame(frame);
}
// A structured event flushes the buffered text and paints the whole
// transcript, so nothing is left stranded for a catch-up tick.
paintedChars = streamed;
publish();
continue;
}
@@ -272,7 +319,10 @@ export function createRunController(deps: ControllerDeps): RunController {
const frames = coalescer.push({ kind: "text", text: event.text }, deps.time.nowMs());
// Two independent bounds. The coalescer decides a frame is worth
// painting; the gate decides the renderer can keep up with it.
if (frames.length > 0 && gate(streamed)) publish();
if (frames.length > 0 && gate(streamed)) {
paintedChars = streamed;
publish();
}
}
} catch (error) {
// A stream that dies BECAUSE WE ABORTED IT is not a failure.
@@ -183,6 +183,26 @@ test("a REFUSED write wakes nobody", async () => {
assert.equal(calls, 0);
});
test("a write whose PERSIST fails rolls back and wakes nobody", async () => {
// `persist` reaches StoragePort, which rejects on a real device
// (SecurityError with site data blocked, QuotaExceededError). Committing the
// value to memory before persisting left `get` returning a value the next
// launch's `load` would never read -- a phantom the settings field then could
// not honestly reset to. The write must be all-or-nothing: on a persist
// failure the value rolls back, the key stays un-chosen, and no subscriber is
// told a change landed.
const { storage, store } = build();
let calls = 0;
store.subscribe(() => void calls++);
storage.failNext("QuotaExceededError: the storage is full");
await assert.rejects(store.set("model", "gpt-4o"), /QuotaExceededError/);
assert.equal(store.get("model"), "gpt-4o-mini", "the value must roll back to what is stored");
assert.equal(store.isSet("model"), false, "a write that did not persist must not mark the key chosen");
assert.equal(calls, 0, "a failed write must not tell a subscriber a change landed");
});
test("storing a secret wakes subscribers", async () => {
// A row reads "configured" from hasSecret; storing a key must redraw it.
const { store } = build();
@@ -181,9 +181,25 @@ export function createSettingsStore(
const validated = def.validate === undefined ? value : def.validate(value);
if (validated === null) return false;
// Persist BEFORE mutating the in-memory map. `persist` reaches
// StoragePort, which rejects on a real device (SecurityError with site
// data blocked, QuotaExceededError under storage pressure). Updating
// `values`/`chosen` first left `get` returning a value the next launch's
// `load` will never read -- a setting the user believes they changed and
// did not, and a field that cannot honestly reset to what is stored. The
// write is committed to memory only once it is durable.
const previous = values.get(key);
const wasChosen = chosen.has(key);
values.set(key, validated);
chosen.add(key);
await persist();
try {
await persist();
} catch (error) {
if (previous === undefined) values.delete(key);
else values.set(key, previous);
if (!wasChosen) chosen.delete(key);
throw error;
}
notify();
return true;
},
+56 -29
View File
@@ -352,48 +352,75 @@ It also used to omit **route→view dispatch**, which the body of this file list
as open and which is still open; dropping it from the closing line implied it
had landed.
## The publish gate is wired and unreachable
## The publish gate was wired and unreachable — CLOSED
`core/src/pacing/publish-gate.ts` is a verbatim port of the desktop's
stream-pacing, with two constants tightened deliberately for mobile
(`MAX_HELD_CHARS = 96`, `UNPAINTED_REOPEN_MS = 200`). It IS called --
`controller.ts` has `if (frames.length > 0 && gate(streamed)) publish()` -- but
in the shipped pipeline that condition is almost never true, so neither
constant affects anything.
(`MAX_HELD_CHARS = 96`, `UNPAINTED_REOPEN_MS = 200`). It WAS called --
`controller.ts` had `if (frames.length > 0 && gate(streamed)) publish()` -- but
in the shipped pipeline that condition was almost never true, so neither
constant affected anything.
The reason is the coalescer's flush tick, added to fix answers arriving in one
The reason was the coalescer's flush tick, added to fix answers arriving in one
lump. The tick drains the coalescer every `maxDelayMs`, so by the time a delta
arrives `push()` usually returns `[]` and the gate is not consulted at all. The
tick's own publish is deliberately ungated, which is correct -- that frame
exists precisely because nothing has painted recently.
arrives `push()` usually returns `[]` and the gate was not consulted at all. The
tick's own publish was ungated, on the reasoning that its frame "exists
precisely because nothing has painted recently."
Measured by driving the real controller with a virtual clock and counting
whether the gate was consulted even once (it calls `requestFrame` on its first
invocation, so the count is exact rather than inferred):
| tokens/sec | publishes for 2000 tokens | gate consulted |
|-----------:|--------------------------:|:---------------|
| 20 | 2004 | no |
| 60 | 2004 | no |
| 150 | 670 | no |
| 400 | 289 | no |
| 1600 | 80 | no |
| 3200 | 42 | YES |
| 8000 | 42 | YES |
| tokens/sec | publishes for 2000 tokens | gate consulted (before) |
|-----------:|--------------------------:|:------------------------|
| 20 | 2004 | no |
| 60 | 2004 | no |
| 150 | 670 | no |
| 400 | 289 | no |
| 1600 | 80 | no |
| 3200 | 42 | YES |
| 8000 | 42 | YES |
The threshold is where 256 bytes land inside a single 16ms window. Real model
streaming is 20-150 tokens/sec, so the gate is unreachable in practice.
The threshold was where 256 bytes land inside a single 16ms window. Real model
streaming is 20-150 tokens/sec, so the gate was unreachable in practice.
This is not currently a defect: paints stay bounded by time (20-38/sec
observed), which is the property that matters, and the per-publish view work is
under 9% of a frame even in the worst measured case. It is recorded because
two things follow from it. There is no backpressure path at all -- the gate is
the mechanism that notices the renderer cannot keep up, and it never runs. And
a module with its own tuned constants that never executes will read as load-
bearing to the next person who changes pacing.
This was not a live defect: paints stayed bounded by time (20-38/sec observed).
But two things followed from it. There was no backpressure path at all -- the
gate is the mechanism that notices the renderer cannot keep up, and it never
ran. And a module with its own tuned constants that never executes reads as
load-bearing to the next person who changes pacing.
Fix it by wiring the tick's publish through the gate, or delete the module and
its constants. Do not leave it looking wired.
**CLOSED by wiring, not deletion.** The flush tick now publishes THROUGH the
gate: `if (gate(streamed)) publish()`. The old "ungated is correct here"
reasoning had the gate's state backwards -- `gate()` returns `true` whenever the
gate is OPEN, and it is open exactly when nothing has painted recently (it opens
on its first call and reopens on a frame callback or after `UNPAINTED_REOPEN_MS`
with no paint). So the tick after a quiet period still paints immediately; the
gate only skips a tick that fires just after a paint, which is backpressure and
the one thing this pipeline lacked. `MAX_HELD_CHARS` still forces the tail out,
so a closed gate cannot swallow the end of an answer, and the final
`finally`-publish is ungated as before. Both constants are now load-bearing.
Pinned by `controller.test.ts` ("the flush tick paints through the publish gate,
so backpressure bounds it"): 40 ticks with no frame release produce a handful of
paints, not 40. Reverting to the ungated tick makes every tick paint (measured:
45 paints for 40 ticks) and fails the bound.
One subtlety the wiring had to answer, or it would have traded a paint-rate
problem for a visibility one. A tick DRAINS the coalescer before the gate is
consulted, so a frame the gate rejects is already gone from the coalescer -- its
text is on the transcript (the run loop applies every event) but not yet
painted. If the stream then PAUSES, no later tick sees pending text and the gate
reopening does not itself publish, so that progress would sit invisible until
the next delta or the final publish -- on a phone, a provider pausing mid-answer
is ordinary, so this is a real stall, not a corner case. The controller tracks
how far the screen has actually painted (`paintedChars`) and, on a tick that
finds nothing new to drain, flushes that stranded progress once the gate has
reopened. This is the backpressure RELEASE the gate needs to be safe: skip a
paint under load, then catch up the moment the renderer can. Pinned by
`controller.test.ts` ("text drained by a rejected tick is still painted when the
stream pauses"): the reopen fires mid-pause and the full answer paints WHILE THE
RUN IS STILL LIVE, not only at `end`.
Five settings used to be declared and consumed by nothing: `model`,
`temperature`, `showReasoning`, `showDiagnostics` and `apiKey` (issue #4636).
@@ -578,3 +578,103 @@ test("a 200 with ok:true IS accepted -- the pair", async () => {
assert.equal(await engine.decide("a1", "allow"), true);
assert.equal(await engine.cancel("r1"), true);
});
// ---- a control request stays with the engine that owns its id ---------------
//
// The resolver fixes the recovery path -- the NEXT /chat reaches the address
// the user just corrected -- but a run's /cancel and an approval's /approve are
// addressed by an id the ORIGINATING engine minted. If a mid-turn address
// change redirected them to the new engine, it would refuse the foreign id;
// controller.ts only aborts the local reader once cancel returns true, so the
// original turn would keep generating at the old address while the user
// believes they stopped it. The id is pinned to the base it was born at.
test("a stop reaches the address the run STARTED at, even after Settings moved it", async () => {
// The real scenario: the run is STILL STREAMING when the user, having gone to
// Settings to fix an address they thought was the problem, taps Stop. The
// generator is suspended at a yield, so the run is live and its pin stands.
let address = "http://first.test";
const http = createFakeHttp();
http.on("/chat", () =>
sseResponse(encodeSseFrame({ type: "start", msgId: "m1", runId: "r1" })),
);
http.on("/cancel/r1", () => ({ status: 200, headers: {}, body: streamOf('{"ok":true}') }));
const engine = createRemoteHttpEngine({ baseUrl: () => address, http });
// Pull the first event and STOP -- the run is mid-stream, not exhausted.
const iterator = engine.run(
{ prompt: "hi", chatId: "c1", runId: "r1", tools: false, regenerateOf: null, attachments: [] },
new AbortController().signal,
)[Symbol.asyncIterator]();
await iterator.next();
address = "http://second.test";
await engine.cancel("r1");
const cancel = http.sent.find((r) => r.url.includes("/cancel"));
assert.equal(
cancel?.url,
"http://first.test/cancel/r1",
"the stop must go to the engine that owns r1, not the newly-typed address",
);
await iterator.return?.();
});
test("an approval decision reaches the engine that ASKED, after the address moved", async () => {
// An approval blocks the run and is answered by an id this engine minted.
// A `deny` sent to a freshly-corrected address hands a foreign id to an
// engine that never issued it -- the run stays blocked at the old one.
let address = "http://first.test";
const http = createFakeHttp();
http.on("/chat", () =>
sseResponse(
encodeSseFrame({ type: "start", msgId: "m1", runId: "r1" }) +
encodeSseFrame({
type: "approval_request",
msgId: "m1",
approvalId: "ap1",
callId: "c1",
name: "rm",
args: {},
}),
),
);
http.on("/approve/ap1", () => ({ status: 200, headers: {}, body: streamOf('{"ok":true}') }));
const engine = createRemoteHttpEngine({ baseUrl: () => address, http });
// Drain until the approval prompt is seen -- the run is now blocked on it.
for await (const event of engine.run(
{ prompt: "hi", chatId: "c1", runId: "r1", tools: true, regenerateOf: null, attachments: [] },
new AbortController().signal,
)) {
if (event.type === "approval_request") break;
}
address = "http://second.test";
await engine.decide("ap1", "deny");
const approve = http.sent.find((r) => r.url.includes("/approve"));
assert.equal(
approve?.url,
"http://first.test/approve/ap1",
"the decision must return to the engine that asked, not the newly-typed address",
);
});
test("with the address unchanged, a control request still follows the live resolver", async () => {
// The pair. Pinning must not mean IGNORING the resolver: a cancel for a run
// this instance never saw (a resumed app) has no pin, and must fall back to
// the current address rather than a stale or empty one.
let address = "http://first.test";
const http = createFakeHttp();
http.on("/cancel/unknown", () => ({ status: 200, headers: {}, body: streamOf('{"ok":true}') }));
const engine = createRemoteHttpEngine({ baseUrl: () => address, http });
address = "http://second.test";
await engine.cancel("unknown");
const cancel = http.sent.find((r) => r.url.includes("/cancel"));
assert.equal(
cancel?.url,
"http://second.test/cancel/unknown",
"an id with no origin pin must use the address in effect now",
);
});
@@ -29,8 +29,18 @@ import { createSseReader } from "../../../protocol/src/sse.ts";
import { classify, type Readiness } from "./readiness.ts";
export interface RemoteHttpOptions {
/** Base URL of the engine, no trailing slash. */
readonly baseUrl: string;
/**
* Base URL of the engine, no trailing slash.
*
* A RESOLVER is accepted as well as a string, and the difference is the
* whole recovery path for a device that cannot reach its engine. This engine
* is constructed once, at boot, and the app holds it for the session -- so a
* captured string means the address the user corrects in Settings is
* persisted, displayed, and then ignored until the app is force-quit. A
* function is re-read per request, so the next message goes where the user
* just said. A plain string still works and still means "this one, forever".
*/
readonly baseUrl: string | (() => string);
readonly http: HttpPort;
/** Sent as a bearer token when present. The desktop engine is unauthenticated
* on loopback; anything off-device must not be. */
@@ -51,9 +61,27 @@ const CAPABILITIES: EngineCapabilities = {
};
export function createRemoteHttpEngine(options: RemoteHttpOptions): AgentEnginePort {
const base = options.baseUrl.replace(/\/+$/, "");
// Resolved per call, never captured. The trailing slash is stripped here
// rather than at the caller because a resolver's answer is not seen until
// now: `${base}/chat` against "http://host/" produces a double slash, which
// some servers 404 and others redirect -- losing the POST body.
const base = (): string =>
(typeof options.baseUrl === "string" ? options.baseUrl : options.baseUrl()).replace(/\/+$/, "");
const id = options.id ?? "remote-http";
// Where each in-flight run was STARTED. A resolver means `base()` can move
// between calls -- that is the whole point, so the next /chat reaches the
// address the user just fixed. But a run's /cancel, and an approval's
// /approve, are addressed by an id the ORIGINATING engine minted; sending
// them to a newly-corrected address hands a foreign id to an engine that
// never issued it, which refuses it -- and controller.ts only aborts the
// local reader once cancel returns true, so the original turn keeps
// generating at the old address while the user believes they stopped it.
// Pinning the id to the base it was born at keeps a mid-turn address change
// from redirecting the control request off the run it belongs to.
const runBase = new Map<string, string>();
const approvalBase = new Map<string, string>();
const headers = (extra: Record<string, string> = {}): Record<string, string> => ({
"content-type": "application/json",
...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
@@ -77,11 +105,11 @@ export function createRemoteHttpEngine(options: RemoteHttpOptions): AgentEngineP
/** POST a small JSON request and read `{ok: boolean}` out of the reply.
* Returns false on anything that is not an explicit success -- reporting
* success for an unknown id "would be a lie the UI cannot detect". */
const postOk = async (path: string, body: unknown): Promise<boolean> => {
const postOk = async (path: string, body: unknown, origin: string): Promise<boolean> => {
try {
const response = await options.http.send({
method: "POST",
url: `${base}${path}`,
url: `${origin}${path}`,
headers: headers(),
body: JSON.stringify(body),
signal: new AbortController().signal,
@@ -106,9 +134,14 @@ export function createRemoteHttpEngine(options: RemoteHttpOptions): AgentEngineP
capabilities: CAPABILITIES,
async *run(request: RunRequest, signal: AbortSignal): AsyncIterable<RunEvent> {
// Resolved ONCE, here, and remembered for this run's control requests.
// The /chat POST and every later /cancel for this runId must reach the
// same engine even if Settings changes the address mid-stream.
const origin = base();
runBase.set(request.runId, origin);
const response = await options.http.send({
method: "POST",
url: `${base}/chat`,
url: `${origin}/chat`,
headers: headers({ accept: "text/event-stream" }),
body: JSON.stringify({
prompt: request.prompt,
@@ -164,6 +197,12 @@ export function createRemoteHttpEngine(options: RemoteHttpOptions): AgentEngineP
}
const outcome = decodeEvent({ ...parsed.value, type: frame.event });
if (isDecoded(outcome)) {
// An approval blocks the run and is answered by an id THIS engine
// minted, so its /approve must land back here even after Settings
// moves the address. Pinned to the run's origin, not `base()`.
if (outcome.event.type === "approval_request") {
approvalBase.set(outcome.event.approvalId, origin);
}
yield outcome.event;
} else {
options.onIgnored?.(outcome.reason, outcome.detail);
@@ -173,15 +212,29 @@ export function createRemoteHttpEngine(options: RemoteHttpOptions): AgentEngineP
} finally {
// Release the socket whether we finished, aborted, or threw.
await reader.cancel().catch(() => {});
// The run is over: its id can no longer be cancelled meaningfully, so
// drop the pin rather than let the map grow for the session. A /cancel
// that races the natural end falls back to `base()` -- harmless, the
// run is already gone. Approval pins outlive the map entry only until
// decided; they are cleared in `decide`.
runBase.delete(request.runId);
}
},
async decide(approvalId: string, choice: ApprovalChoice): Promise<boolean> {
return postOk(`/approve/${encodeURIComponent(approvalId)}`, { choice });
// The engine that asked is the engine that must be told. Fall back to the
// live resolver only for an id this instance never saw (a resumed app
// that reconnected), where the current address is the best guess left.
const origin = approvalBase.get(approvalId) ?? base();
const ok = await postOk(`/approve/${encodeURIComponent(approvalId)}`, { choice }, origin);
approvalBase.delete(approvalId);
return ok;
},
async cancel(runId: string): Promise<boolean> {
return postOk(`/cancel/${encodeURIComponent(runId)}`, {});
// Pinned to where the run STARTED. A mid-turn address change must not
// redirect the stop to an engine that never issued this runId.
return postOk(`/cancel/${encodeURIComponent(runId)}`, {}, runBase.get(runId) ?? base());
},
async dispose(): Promise<void> {
+218
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^22.20.1",
"esbuild": "^0.25.0",
"typescript": "^5.7.3"
@@ -459,6 +460,223 @@
"node": ">=18"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+3 -1
View File
@@ -17,9 +17,11 @@
"check": "npm run typecheck && npm run boundaries && npm run test",
"check:upstream": "node tools/check-upstream-parity.mjs",
"build:webview": "node tools/build-webview.mjs",
"test:rust": "cd src-tauri && cargo test"
"test:rust": "cd src-tauri && cargo test",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^22.20.1",
"esbuild": "^0.25.0",
"typescript": "^5.7.3"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -18,7 +18,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https: http://127.0.0.1:* http://localhost:*"
},
"withGlobalTauri": true
},
+71 -9
View File
@@ -41,6 +41,8 @@ interface FakeNode {
append(...nodes: FakeNode[]): void;
insertBefore(node: FakeNode, ref: FakeNode | null): FakeNode;
remove(): void;
focus(): void;
readonly isConnected: boolean;
addEventListener(type: string, cb: (e: unknown) => void): void;
removeEventListener(type: string, cb: (e: unknown) => void): void;
dispatch(type: string, event: unknown): void;
@@ -62,7 +64,22 @@ export interface FakeDom {
find(pred: (n: FakeNode) => boolean): FakeNode | null;
/** Click an element, bubbling to delegated listeners on its ancestors. */
click(el: FakeNode): { defaultPrevented: boolean };
/**
* Commit a field, bubbling like the real `change` event does.
*
* `change` BUBBLES in a real DOM, which is what lets one delegated listener
* on root hear every settings field -- the same arrangement `click` already
* uses here. Dispatching it on the field alone (`el.dispatch("change", {})`)
* only ever reaches a listener attached to that element, so a test written
* that way passes against a per-field listener and reports nothing at all
* against a delegated one. Modelling the bubble is what makes the two
* indistinguishable to a test, as they are to a browser.
*/
change(el: FakeNode): { defaultPrevented: boolean };
text(): string;
/** The element `focus()` was last called on, or null -- what a test asserts
* a route change moved focus to. Mirrors `document.activeElement`. */
activeElement(): FakeNode | null;
}
@@ -90,6 +107,10 @@ export function createFakeDom(): FakeDom {
installDomGlobals();
const listeners = new WeakMap<FakeNode, Map<string, Set<(e: unknown) => void>>>();
const viewListeners = new Map<string, Set<(e: unknown) => void>>();
// The focused element, as the real `document.activeElement` would report it.
// A route change must MOVE this; the whole point of the focus fix is that it
// does, rather than leaving it on the screen the user just left.
let active: FakeNode | null = null;
const make = (tag: string): FakeNode => {
// Built imperatively rather than as one literal: the accessors below refer
@@ -159,7 +180,25 @@ export function createFakeDom(): FakeDom {
const at = parent.children.indexOf(el);
if (at !== -1) parent.children.splice(at, 1);
el.parentElement = null;
// A removed element cannot keep focus. The real DOM drops focus to
// <body> here; the app's `restore` fallback depends on `isConnected`
// being false after this, so mirror it.
if (active === el) active = null;
};
el.focus = (): void => { active = el; };
// Connected when a walk up parents reaches root. The app checks this before
// restoring focus to a saved element, because the row it came from may have
// been removed (e.g. the deleted chat you were viewing).
Object.defineProperty(el, "isConnected", {
get(): boolean {
let node: FakeNode | null = el;
while (node !== null) {
if (node === root) return true;
node = node.parentElement;
}
return false;
},
});
el.addEventListener = (type: string, cb: (e: unknown) => void): void => {
let byType = listeners.get(el);
if (byType === undefined) { byType = new Map(); listeners.set(el, byType); }
@@ -190,9 +229,31 @@ export function createFakeDom(): FakeDom {
},
};
const doc = { createElement: make, defaultView: view, addEventListener() {}, getElementById: () => null };
const doc = {
createElement: make,
defaultView: view,
addEventListener() {},
getElementById: () => null,
// The app reads this before a push to remember where to return focus on the
// matching pop. A getter, not a snapshot, so it tracks `focus()` calls.
get activeElement(): FakeNode | null { return active; },
};
const root = make("div");
/** Dispatch one event up the ancestor chain, as a real bubbling event does.
* Shared by `click` and `change` so the two cannot drift: a delegated
* listener on root must hear both or neither. */
const bubble = (type: string, el: FakeNode): { defaultPrevented: boolean } => {
const event = {
target: el,
defaultPrevented: false,
preventDefault(this: { defaultPrevented: boolean }) { this.defaultPrevented = true; },
};
let node: FakeNode | null = el;
while (node !== null) { node.dispatch(type, event); node = node.parentElement; }
return event;
};
const all = (): FakeNode[] => {
const out: FakeNode[] = [];
const walk = (n: FakeNode): void => { out.push(n); for (const c of n.children) walk(c); };
@@ -207,15 +268,16 @@ export function createFakeDom(): FakeDom {
all,
find: (pred) => all().find(pred) ?? null,
click(el) {
const event = {
target: el,
defaultPrevented: false,
preventDefault(this: { defaultPrevented: boolean }) { this.defaultPrevented = true; },
};
let node: FakeNode | null = el;
while (node !== null) { node.dispatch("click", event); node = node.parentElement; }
return event;
// A real click focuses the control it lands on, so the app's "remember
// where to return on Back" reads the tapped row, not stale focus.
active = el;
return bubble("click", el);
},
// No focus side effect: a `change` fires on a field the user is ALREADY in
// (or has just left), and moving focus here would paper over an app that
// forgot to.
change: (el) => bubble("change", el),
text: () => root.textContent,
activeElement: () => active,
};
}
@@ -0,0 +1,72 @@
/**
* The esbuild target, checked against the platform minimums the app declares.
*
* These are two numbers in two files that must agree, and nothing linked them.
* `tools/bundle.mjs` said `chrome108` while `tauri.conf.json` declared
* `minSdkVersion: 26` -- Android 8.0, whose factory WebView is Chrome ~58.
*
* The failure is silent and total: `index.html` loads the bundle as
* `<script type="module">`, so post-Chrome-58 syntax is a PARSE error and the
* module body never runs. `installCrashHandler` is imported by that same
* module, so it never installs -- a blank white screen, no error surface, no
* telemetry, on exactly the devices (AOSP, Play-less, long-offline) that a
* WebView floor exists to protect.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bundle, TARGETS, ANDROID_WEBVIEW_FLOOR } from "./bundle.mjs";
const conf = JSON.parse(
readFileSync(join(import.meta.dirname, "../src-tauri/tauri.conf.json"), "utf8"),
);
const target = (prefix) => TARGETS.find((t) => t.startsWith(prefix));
test("the Chrome target matches the Android minSdkVersion the app declares", () => {
const minSdk = conf.bundle.android.minSdkVersion;
const required = ANDROID_WEBVIEW_FLOOR[minSdk];
assert.ok(
required !== undefined,
`minSdkVersion ${minSdk} has no WebView floor recorded in bundle.mjs -- add one rather than guessing`,
);
assert.equal(
target("chrome"),
required,
`minSdkVersion ${minSdk} ships a WebView at ${required}; the bundle targets ${target("chrome")}`,
);
});
test("the Safari target matches the iOS minimum the app declares", () => {
// This pair already agreed. Asserted so it keeps agreeing, and so the test
// above is not the only thing holding the relationship.
const major = Number(String(conf.bundle.iOS.minimumSystemVersion).split(".")[0]);
assert.equal(target("safari"), `safari${major}`, "iOS 16 ships Safari 16");
});
test("the shipped bundle contains no syntax the floor cannot parse", async () => {
// The assertion that would actually have caught it. Optional chaining and
// nullish coalescing are Chrome 80; logical assignment is Chrome 85. Any of
// them at a chrome58 floor is a blank screen.
//
// Built here through the same bundle() at the same TARGETS the ship path
// uses, rather than read from a pre-built dist/app.js: the `check` job runs
// `npm test` without a prior `npm run build`, so a disk read would be a test
// that can only pass by accident of build order. Same entry, same targets,
// same output -- with no ordering dependency.
const out = join(mkdtempSync(join(tmpdir(), "floor-")), "app.js");
const { code } = await bundle({
entry: join(import.meta.dirname, "../app/src/main.ts"),
outfile: out,
write: false,
});
for (const [name, pattern] of [
["optional chaining", /\?\./],
["nullish coalescing", /\?\?[^=]/],
["logical assignment", /\?\?=|\|\|=|&&=/],
]) {
assert.doesNotMatch(code, pattern, `${name} is post-Chrome-58 and will not parse on the floor`);
}
});
+91 -2
View File
@@ -19,8 +19,26 @@
*/
import * as esbuild from "esbuild";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { resolve } from "node:path";
/** Node builtins that must never survive into a webview bundle. */
/** esbuild's own metafile markers, which are not packages. */
const RUNTIME_MARKERS = new Set(["<runtime>"]);
/** Every Node builtin, not only the forbidden ones. A builtin is already
* classified as fatal-or-lazy above; this list exists so the unresolved check
* does not report one twice under a different heading. */
const NODE_BUILTINS = [
"assert", "async_hooks", "buffer", "child_process", "cluster", "console",
"constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
"events", "fs", "http", "http2", "https", "inspector", "module", "net",
"os", "path", "perf_hooks", "process", "punycode", "querystring",
"readline", "repl", "stream", "string_decoder", "sys", "timers", "tls",
"trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads",
"zlib",
];
export const FORBIDDEN_BUILTINS = [
"assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns",
"events", "fs", "http", "http2", "https", "module", "net", "os", "path",
@@ -37,7 +55,30 @@ export const FORBIDDEN_BUILTINS = [
* Play, but a device kept offline keeps whatever it shipped with -- so the
* floor there is the OS too, not the current Chrome.
*/
export const TARGETS = ["safari16", "chrome108"];
/**
* The oldest engines the bundle must PARSE on, derived from the platform
* minimums the app declares -- not chosen independently of them.
*
* `chrome108` was set here while `tauri.conf.json` declared
* `minSdkVersion: 26` (Android 8.0, WebView ~Chrome 58). The output contains
* optional chaining, nullish coalescing and logical assignment -- all
* post-Chrome-58 -- and `index.html` loads it as `<script type="module">`, so
* on such a device the module body never evaluates. `installCrashHandler` is
* imported by that same module, so it never installs either: a blank white
* screen with no error surface and no telemetry.
*
* Android's WebView updates through Play, so most devices are far newer. The
* ones that are not -- AOSP builds, Play-less devices, anything kept offline --
* are exactly the population this floor exists to protect, which is what the
* comment above already said before the two numbers drifted apart.
*
* Lowering the floor cost 5.2kB of a 400kB budget, measured.
*
* `bundle-target.test.mjs` asserts these stay derived from the declared
* minimums, because nothing linked the two numbers and they silently diverged.
*/
export const ANDROID_WEBVIEW_FLOOR = { 26: "chrome58", 30: "chrome87", 33: "chrome108" };
export const TARGETS = ["safari16", "chrome58"];
/** Bytes. Deliberately tight: this is a text UI, and the budget is what makes
* a dependency a decision rather than an accident. */
@@ -146,6 +187,46 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
const forbidden = forbiddenAmong(bare);
const fatal = forbidden.filter((p) => classified.get(p) === "static");
const lazy = forbidden.filter((p) => classified.get(p) === "dynamic");
// A bare import that is NOT a Node builtin and still left the build as an
// external is one esbuild could not resolve -- the package is not installed.
//
// A webview has no module resolver. `import "openai"` in the shipped file is
// a hard failure at import time, before any code runs, with the same blank
// screen as a Node builtin -- and until now the gate said `shippable: true`
// for it, because `problems` was built only from forbidden BUILTINS, top
// level process.env, and size.
//
// Measured: bundling praisonai-ts's webview entry through this gate reported
// 80.5kB, 0 problems and `shippable: true` while silently leaving out
// openai, ai, @ai-sdk/cohere, @ai-sdk/google, @ai-sdk/openai,
// @ai-sdk/provider-utils, chalk, ora, boxen, figlet and cli-table3. That
// bundle loads on a laptop with node_modules beside it and dies on a phone.
//
// CORRECTED. The first version of this asked "did it stay external?", which
// is true of EVERY bare import here -- the plugin below externalises them all
// on purpose, so builtins surface in the metafile instead of being shimmed.
// So it flagged installed, perfectly resolvable packages as unresolvable, and
// only passed because the shipped bundle happens to have no bare imports at
// all. Confirmed against a real install: `praisonai/mobile` resolves to
// node_modules/praisonai/dist/mobile.js and was reported unresolved anyway.
//
// The question that actually matters is whether the specifier can be
// RESOLVED from the entry, so it is asked directly.
const resolver = createRequire(resolve(entry));
const unresolved = bare.filter((name) => {
if (RUNTIME_MARKERS.has(name)) return false;
// Redundant in practice and kept for intent: `createRequire` resolves
// builtins too, so removing this line changes nothing (verified). A
// builtin is already classified as fatal-or-lazy above and must not be
// reported twice under a different heading -- that is what this says.
if (NODE_BUILTINS.includes(name.split("/")[0])) return false;
try {
resolver.resolve(name);
return false;
} catch {
return true;
}
});
const processReads = topLevelProcessReads(code);
const bytes = Buffer.byteLength(code, "utf8");
@@ -164,13 +245,21 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
`\n A webview has no process. Route these through a guarded accessor.`,
);
}
if (unresolved.length > 0) {
problems.push(
`bare imports the build could not resolve: ${unresolved.join(", ")}.\n` +
` A webview has no module resolver, so these fail at IMPORT time and the\n` +
` screen stays blank -- exactly like a static Node builtin. Add them as\n` +
` dependencies so they are bundled, or stop importing them.`,
);
}
if (bytes > SIZE_BUDGET_BYTES) {
problems.push(
`bundle is ${(bytes / 1024).toFixed(1)}kB, over the ${(SIZE_BUDGET_BYTES / 1024).toFixed(0)}kB budget.`,
);
}
return { bytes, bare, forbidden, fatal, lazy, processReads, problems, metafile: result.metafile, code };
return { bytes, bare, forbidden, fatal, lazy, unresolved, processReads, problems, metafile: result.metafile, code };
}
/** CLI. Guarded so importing this module for tests does not run a build. */
@@ -10,6 +10,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -259,3 +260,74 @@ test("a relative import that is external is not reported as a bare module", () =
});
assert.deepEqual([...bare.keys()], ["crypto"], "only the bare specifier is bare");
});
test("the shipping bundle resolves everything it imports", async () => {
// The positive control, and the one that would actually catch a regression:
// the real app entry, through the real gate.
const out = join(mkdtempSync(join(tmpdir(), "ship-")), "app.js");
const report = await bundle({ entry: "app/src/main.ts", outfile: out, write: false });
assert.deepEqual(report.unresolved, [], `the shipped bundle must resolve everything: ${report.unresolved}`);
assert.equal(isShippable(report), true, report.problems.join("\n"));
});
test("an unresolvable import fails the gate, with the package named", async () => {
// Driven through the REAL bundler against a real unresolvable specifier,
// rather than a hand-built report -- the check has to survive esbuild's
// actual externals behaviour, not my model of it.
const dir = mkdtempSync(join(tmpdir(), "unres-"));
const entry = join(dir, "probe.ts");
writeFileSync(entry, 'import x from "a-package-that-is-not-installed";\nexport const y = x;\n');
const report = await bundle({ entry, outfile: join(dir, "o.js"), write: false });
assert.equal(isShippable(report), false, "an unresolvable import must not be shippable");
assert.ok(
report.unresolved.includes("a-package-that-is-not-installed"),
`it must be listed: ${JSON.stringify(report.unresolved)}`,
);
assert.match(report.problems.join("\n"), /could not resolve/);
assert.match(report.problems.join("\n"), /a-package-that-is-not-installed/, "and named in the message");
});
test("a bare import that IS installed is not reported unresolvable", async () => {
// The correction. The first version of this check asked "did it stay
// external?" -- which is true of every bare import, because the plugin
// externalises them all on purpose so builtins surface in the metafile. So
// it flagged installed, resolvable packages, and only passed because the
// shipped bundle happens to have no bare imports at all.
//
// `esbuild` is a real dependency of this package, so it is the honest probe:
// resolvable from here, and nothing to do with Node builtins.
// The probe lives INSIDE the package, because resolution is relative to the
// entry: a temp-directory entry has no node_modules above it, so everything
// would look missing and the test would pass for the wrong reason. The real
// app entry is inside the package too.
const entry = join(import.meta.dirname, ".resolvable-probe.ts");
writeFileSync(entry, 'import * as e from "esbuild";\nexport const x = e;\n');
try {
const report = await bundle({
entry,
outfile: join(mkdtempSync(join(tmpdir(), "installed-")), "o.js"),
write: false,
});
assert.deepEqual(report.unresolved, [], "an installed package resolves");
assert.equal(isShippable(report), true, report.problems.join("\n"));
} finally {
rmSync(entry, { force: true });
}
});
test("a LAZY Node builtin is still allowed -- the pair", async () => {
// The unresolved check must not swallow the static-vs-dynamic distinction
// this file's header is built on: a dynamic `await import("readline")` in a
// CLI-only path is unavailable on a phone, not fatal.
const dir = mkdtempSync(join(tmpdir(), "lazy-"));
const entry = join(dir, "probe.ts");
writeFileSync(entry, 'export async function cli() { return await import("readline"); }\n');
const report = await bundle({ entry, outfile: join(dir, "o.js"), write: false });
assert.deepEqual(report.unresolved, [], "a builtin is classified as a builtin, not as unresolved");
assert.deepEqual(report.fatal, [], "and a dynamic one is not fatal");
});
+12 -1
View File
@@ -354,7 +354,18 @@ test("the size budget and the webview targets are the values the gate claims", (
// whose threshold can be edited without a failing test is a gate that can be
// turned off.
assert.equal(SIZE_BUDGET_BYTES, 400 * 1024, "the budget is what makes a dependency a decision");
assert.deepEqual(TARGETS, ["safari16", "chrome108"], "the WebView floor is the OS, not the current Chrome");
// The Chrome floor is DERIVED from `minSdkVersion`, not restated here. This
// line said `chrome108` while tauri.conf.json declared `minSdkVersion: 26`
// -- Android 8.0, WebView ~Chrome 58 -- so the two numbers disagreed and a
// literal in this test was what made the disagreement look deliberate.
// `bundle-target.test.mjs` holds the relationship; this holds the shape.
assert.equal(TARGETS.length, 2, "one floor per platform");
assert.equal(TARGETS.find((t) => t.startsWith("safari")), "safari16", "iOS 16 is the declared minimum");
assert.match(
TARGETS.find((t) => t.startsWith("chrome")) ?? "",
/^chrome\d+$/,
"the WebView floor is the OS, not the current Chrome",
);
});
// ---- the allowlist glob is part of the gate, not a convenience -------------
@@ -0,0 +1,80 @@
/**
* Write the release version, and a monotonic build number, into the Tauri
* config the mobile bundler reads.
*
* The desktop app shipped v4.7.3 whose installer reported ProductVersion
* 0.1.0, because `tauri.conf.json` was hardcoded and the bundler read it
* verbatim (#4527). `praisonai-mobile` has the identical hardcoded `0.1.0`,
* and on a store the consequence is worse than a wrong label:
*
* - Tauri derives Android `versionCode` from this file. Play REJECTS an
* upload whose versionCode has been used before.
* - It derives iOS `CFBundleVersion` from it. App Store Connect rejects a
* duplicate CFBundleVersion within a CFBundleShortVersionString.
*
* So with the version frozen, the FIRST upload succeeds and the second is
* refused outright. A release process that works exactly once is not one.
*
* `tauri.conf.json` is the single source the bundler reads -- deliberately not
* synced with package.json or Cargo.toml, the same single-source rule the
* desktop package settled on.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
const CONFIG = fileURLToPath(new URL("../src-tauri/tauri.conf.json", import.meta.url));
const NUMERIC = "(?:0|[1-9]\\d*)";
const PRERELEASE = `(?:${NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
const SEMVER = new RegExp(
`^${NUMERIC}\\.${NUMERIC}\\.${NUMERIC}(?:-${PRERELEASE}(?:\\.${PRERELEASE})*)?` +
"(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
/** Strip one conventional `v` prefix and validate a strict semantic version. */
export function releaseVersion(tag) {
const version = tag?.startsWith("v") ? tag.slice(1) : tag;
if (!version || !SEMVER.test(version)) {
throw new Error(`release tag "${tag ?? ""}" is not a semantic version`);
}
return version;
}
/**
* The store build number: strictly increasing, and independent of semver.
*
* Semver alone cannot serve. Both stores need a number that rises on EVERY
* upload, including a re-upload of the same version after a rejection -- which
* is precisely when a release is most likely to need one. CI passes
* `github.run_number`, which only ever increases for a repository.
*/
export function buildNumber(run) {
const n = Number(run);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`build number "${run ?? ""}" is not a positive integer`);
}
return n;
}
/** Write both into the Tauri config consumed by the bundler. */
export function setReleaseVersion(tag, run, configPath = CONFIG) {
const config = JSON.parse(readFileSync(configPath, "utf8"));
config.version = releaseVersion(tag);
if (run !== undefined) {
config.bundle ??= {};
config.bundle.android ??= {};
config.bundle.iOS ??= {};
config.bundle.android.versionCode = buildNumber(run);
config.bundle.iOS.buildNumber = String(buildNumber(run));
}
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
return { version: config.version, build: run === undefined ? null : buildNumber(run) };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const { version, build } = setReleaseVersion(process.argv[2], process.argv[3]);
console.log(`mobile release version: ${version}${build === null ? "" : ` (build ${build})`}`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
@@ -0,0 +1,80 @@
/**
* The release version writer.
*
* Desktop shipped an installer reporting ProductVersion 0.1.0 because its
* Tauri config was hardcoded (#4527). Mobile had the identical hardcoded
* 0.1.0 and, on a store, the second upload is rejected outright rather than
* merely mislabelled -- Play refuses a repeated versionCode and App Store
* Connect a repeated CFBundleVersion.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { releaseVersion, buildNumber, setReleaseVersion } from "./set-release-version.mjs";
const fixture = (version = "0.1.0") => {
const path = join(mkdtempSync(join(tmpdir(), "conf-")), "tauri.conf.json");
writeFileSync(path, JSON.stringify({
version,
identifier: "ai.praison.mobile",
bundle: { iOS: { minimumSystemVersion: "16.0" }, android: { minSdkVersion: 26 } },
}, null, 2));
return path;
};
test("a release tag becomes the version the bundler reads", () => {
const path = fixture();
const { version } = setReleaseVersion("v4.8.0", "17", path);
assert.equal(version, "4.8.0");
assert.equal(JSON.parse(readFileSync(path, "utf8")).version, "4.8.0", "written, not just returned");
});
test("one conventional v is stripped, and only one", () => {
assert.equal(releaseVersion("v1.2.3"), "1.2.3");
assert.equal(releaseVersion("1.2.3"), "1.2.3");
assert.throws(() => releaseVersion("vv1.2.3"), /not a semantic version/);
});
test("a tag that is not semver is refused, not guessed at", () => {
// A bad tag must stop the release. Writing something plausible would ship a
// version nobody chose, which is how #4527 shipped in the first place.
for (const tag of ["", undefined, "latest", "1.2", "v1.2.3.4", "release-1"]) {
assert.throws(() => releaseVersion(tag), /not a semantic version/, String(tag));
}
});
test("the build number rises on every upload, independent of semver", () => {
// The case semver cannot serve: a re-upload of the SAME version after a
// store rejection still needs a higher number, and that is exactly when a
// release most needs one.
const path = fixture();
const first = setReleaseVersion("v4.8.0", "17", path);
const second = setReleaseVersion("v4.8.0", "18", path);
assert.equal(first.version, second.version, "the same release");
assert.ok(second.build > first.build, "with a higher build number");
const conf = JSON.parse(readFileSync(path, "utf8"));
assert.equal(conf.bundle.android.versionCode, 18, "Play reads versionCode as an integer");
assert.equal(conf.bundle.iOS.buildNumber, "18", "and CFBundleVersion is a string");
});
test("a build number that cannot increase is refused", () => {
for (const run of ["0", "-1", "1.5", "abc", ""]) {
assert.throws(() => buildNumber(run), /positive integer/, String(run));
}
});
test("the platform minimums survive the rewrite -- the pair", () => {
// The writer rewrites the whole file. Dropping a key it does not manage
// would silently lower the deployment target.
const path = fixture();
setReleaseVersion("v4.8.0", "17", path);
const conf = JSON.parse(readFileSync(path, "utf8"));
assert.equal(conf.bundle.iOS.minimumSystemVersion, "16.0");
assert.equal(conf.bundle.android.minSdkVersion, 26);
assert.equal(conf.identifier, "ai.praison.mobile");
});
@@ -0,0 +1,58 @@
/**
* The Tauri config, checked against what the app actually does.
*
* `tauri.conf.json` is not covered by the TypeScript suite and is not compiled
* by `cargo test`, so every value in it was unasserted. Two of them decide
* whether the app can function on a device at all.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const conf = JSON.parse(
readFileSync(join(import.meta.dirname, "../src-tauri/tauri.conf.json"), "utf8"),
);
const directive = (name) =>
(conf.app.security.csp.split(";").find((d) => d.trim().startsWith(name)) ?? "").trim();
test("the CSP permits the engine addresses the settings allow", () => {
// `connect-src 'self' ipc: http://ipc.localhost` permitted NO engine at all.
// `baseUrl` is user-settable to any host (app/src/registry.ts:58), the HTTP
// adapter under Tauri is still browser `fetch` (app/src/platform.ts), and a
// webview enforces connect-src on fetch -- so every remote engine was
// blocked before it reached the network, including the shipped default of
// http://127.0.0.1:8765.
//
// The desktop app already had the loopback entries; mobile did not, and
// mobile additionally needs https:, because on a phone the engine is across
// a network rather than on the same machine.
const connect = directive("connect-src");
assert.match(connect, /https:/, "an https engine address must be reachable");
assert.match(connect, /http:\/\/127\.0\.0\.1:\*/, "and the shipped loopback default");
assert.match(connect, /\bipc:/, "and Tauri's own IPC");
});
test("the CSP still refuses remote SCRIPTS -- the pair", () => {
// Widening connect-src must not widen the thing that matters. A model can
// return arbitrary text and a tool result is attacker-shaped in the ordinary
// case; script-src is what stops that becoming code in the app's origin.
const script = directive("script-src");
assert.equal(script, "script-src 'self'", `script-src must stay closed: ${script}`);
assert.doesNotMatch(script, /unsafe-inline|unsafe-eval|https:/);
assert.match(directive("default-src"), /^default-src 'self'$/);
});
test("the mobile platform minimums are declared", () => {
// Tauri needs these to generate the platform projects; a missing value is a
// default that may be below what the code assumes.
assert.equal(conf.bundle.iOS.minimumSystemVersion, "16.0");
assert.equal(conf.bundle.android.minSdkVersion, 26);
});
test("the identifier is a real reverse-DNS id, not the Tauri placeholder", () => {
// Both stores key the app on this forever, and Tauri's own template value
// ships as `com.tauri.dev`, which cannot be uploaded.
assert.match(conf.identifier, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/, conf.identifier);
assert.doesNotMatch(conf.identifier, /tauri\.dev|example|todo/i);
});
@@ -20,6 +20,8 @@ import { UNKNOWN } from "../format.ts";
* asserted below, so a new function key cannot be added without a sample. */
const SAMPLES: Readonly<Record<string, () => string>> = {
bootFailed: () => en.bootFailed("no storage"),
engineNotReady: () => en.engineNotReady("ECONNREFUSED"),
settingRejected: () => en.settingRejected("Engine address"),
chatUnreadable: () => en.chatUnreadable("chat-7"),
chatsAllUnreadable: () => en.chatsAllUnreadable(3),
minutesAgo: () => en.minutesAgo(2, "2"),
@@ -97,6 +99,9 @@ test("no string is blank and no parameterised string drops its argument", () =>
assert.ok(en.chatUnreadable("chat-7").includes("chat-7"));
assert.ok(en.errorRowName("auth", "bad key").includes("bad key"));
assert.ok(en.droppedEvents(2, ["wrong_msg_id"]).includes("wrong_msg_id"));
// The refusal has to NAME the setting: the field it belongs to may already
// be off screen, and "that value was refused" alone says which of nothing.
assert.ok(en.settingRejected("Engine address").includes("Engine address"));
});
test("every sample corresponds to a key that still exists", () => {
@@ -62,6 +62,10 @@ export interface Strings {
/** The app could not start at all. The detail is machine text appended, not
* interpolated into the middle of a sentence. */
readonly bootFailed: (detail: string) => string;
/** The app started but the engine is not answering yet. Says the app is
* usable and what is wrong, which is the difference between a warning
* and the fatal screen this used to be. */
readonly engineNotReady: (detail: string) => string;
/** The crash screen. It has to say the conversations survived, because the
* user's next thought is that they did not. */
readonly crashed: string;
@@ -74,6 +78,17 @@ export interface Strings {
readonly routeChat: string;
readonly routeSettings: string;
readonly routeAbout: string;
/**
* A settings value the store would not accept.
*
* Said rather than merely undone. A field that snaps back and says nothing
* is indistinguishable from a mis-tap, a lost keystroke, or a save that
* worked -- and on the settings screen, whose whole job is to repair an
* engine the app cannot reach, that silence leaves someone re-typing the
* same refused value. It names the setting, because the field it belongs to
* may already have scrolled off.
*/
readonly settingRejected: (label: string) => string;
// ---- chat list ---------------------------------------------------------
/** A chat whose title is blank. A blank row has no hit target and reads as a
@@ -256,6 +271,8 @@ export const en: Strings = {
appName: "PraisonAI",
newChat: "New chat",
bootFailed: (detail) => `PraisonAI could not start: ${detail}`,
engineNotReady: (detail) =>
`The engine is not answering yet (${detail}). You can still type; sending will retry it.`,
stopRefused: "The engine did not accept the stop. It may still be running.",
crashed: "Something went wrong. Your conversations are saved.",
@@ -263,6 +280,10 @@ export const en: Strings = {
routeChat: "Chat",
routeSettings: "Settings",
routeAbout: "About",
// The label, then what happened to it -- not "Invalid value", which names
// neither the setting nor the outcome. "was not changed" is the fact the
// user needs: the old value is still in force.
settingRejected: (label) => `${label} was not changed: that value was refused.`,
untitled: "Untitled",
chatUnreadable: (id) => `Could not be read: ${id}`,
+9 -10
View File
@@ -3,12 +3,12 @@
"lastUpdated": "2026-09-02",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"status": "IN_PROGRESS",
"summary": {
"pythonCoreFeatures": 411,
"rustFeatures": 667,
"gapCount": 129,
"parityPercentage": 162.3
"gapCount": 128,
"parityPercentage": 68.9
},
"pythonCoreSDK": {
"exports": [
@@ -426,7 +426,7 @@
]
},
"rustSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-rust",
"path": "src/praisonai-rust",
"exports": [
"A2A",
"A2AAgentCapabilities",
@@ -1181,6 +1181,7 @@
"WebSearchProvider"
],
"config_loader": [
"AutoRagConfig",
"SessionConfig"
],
"context": [
@@ -1816,9 +1817,6 @@
"sandbox": [],
"session": [],
"skills": [],
"specialized": [
"AutoRagConfig"
],
"specialized_agents": [],
"streaming": [],
"task": [],
@@ -2528,11 +2526,12 @@
{
"feature": "loop",
"python": true,
"rust": false,
"rust": true,
"priority": "P1",
"effort": "low",
"status": "TODO",
"category": "workflows"
"status": "DONE",
"category": "workflows",
"rustName": "loop_step"
},
{
"feature": "parallel",
File diff suppressed because it is too large. Load diff
+110 -77
View File
@@ -5,9 +5,14 @@
> [!IMPORTANT]
> **What this measures:** whether a matching *exported symbol name* exists in
> the TypeScript SDK's public surface (parsed from `src/index.ts`). It does **not**
> verify that the capability is reachable, wired up, or behaves like its Python
> counterpart. A `✅ exported` cell means the name is exported — not that it works.
> the TypeScript SDK's public surface (parsed from `src/index.ts`, following
> `export * from` re-exports). It does **not** verify that the capability is
> reachable, wired up, or behaves like its Python counterpart. A `✅ exported`
> cell means the name is exported — not that it works. A `⚠️ stub exported`
> cell means the *only* provider of the name is the `src/parity` shim module,
> which exists to satisfy this tracker's name matching rather than to implement
> the feature. Snake_case Python names are also matched against their camelCase
> spelling (shown as `→ tsName`).
> Counts include barrel re-exports, so `TypeScript Features` reflects module
> structure, not distinct capabilities, and is not directly comparable to the
> Python count. For a capability with a testable contract, rely on its conformance
@@ -19,52 +24,61 @@
|--------|-------|
| Python Core Features | 411 |
| Python Wrapper Features | 21 |
| TypeScript Features | 1194 |
| **Gap Count** | **116** |
| TypeScript Features | 1370 |
| **Gap Count** | **115** |
| Stub Exported (parity shim only) | 56 |
| P0 (Critical) | 2 |
| P1 (High) | 5 |
| P2 (Medium) | 6 |
| P3 (Low) | 103 |
| P3 (Low) | 102 |
## Gap Matrix
### P0_CoreParity (52 exported, 2 missing)
### P0_CoreParity (31 exported, 21 stub, 2 missing)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
| `HandoffToolPolicy` | ✅ | ❌ | high | ⏳ missing |
| `parallel\_handoffs` | ✅ | ❌ | low | ⏳ missing |
| `AudioConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `CodeAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `CodeConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `CodeExecutionStep` | ✅ | ✅ | high | ⚠️ stub exported |
| `DeepResearchResponse` | ✅ | ✅ | high | ⚠️ stub exported |
| `EmbeddingAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `FileSearchCall` | ✅ | ✅ | high | ⚠️ stub exported |
| `MCPCall` | ✅ | ✅ | high | ⚠️ stub exported |
| `OCRAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `OCRConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `Provider` | ✅ | ✅ | high | ⚠️ stub exported |
| `RealtimeAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `RealtimeConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `VideoAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `VideoConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `VisionAgent` | ✅ | ✅ | high | ⚠️ stub exported |
| `VisionConfig` | ✅ | ✅ | low | ⚠️ stub exported |
| `WebSearchCall` | ✅ | ✅ | high | ⚠️ stub exported |
| `create\_context\_agent` | ✅ | ✅ | low | ⚠️ stub exported |
| `handoff\_filters` | ✅ | ✅ | low | ⚠️ stub exported |
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ⚠️ stub exported |
| `Agent` | ✅ | ✅ | high | ✅ exported |
| `AudioAgent` | ✅ | ✅ | high | ✅ exported |
| `AudioConfig` | ✅ | ✅ | low | ✅ exported |
| `BaseTool` | ✅ | ✅ | high | ✅ exported |
| `CodeAgent` | ✅ | ✅ | high | ✅ exported |
| `CodeConfig` | ✅ | ✅ | low | ✅ exported |
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ exported |
| `ContextAgent` | ✅ | ✅ | high | ✅ exported |
| `ContextPolicy` | ✅ | ✅ | high | ✅ exported |
| `DeepResearchAgent` | ✅ | ✅ | high | ✅ exported |
| `DeepResearchResponse` | ✅ | ✅ | high | ✅ exported |
| `EmbeddingAgent` | ✅ | ✅ | high | ✅ exported |
| `EmbeddingConfig` | ✅ | ✅ | low | ✅ exported |
| `ExpandResult` | ✅ | ✅ | low | ✅ exported |
| `ExpandStrategy` | ✅ | ✅ | high | ✅ exported |
| `FileSearchCall` | ✅ | ✅ | high | ✅ exported |
| `FunctionTool` | ✅ | ✅ | high | ✅ exported |
| `Handoff` | ✅ | ✅ | high | ✅ exported |
| `HandoffConfig` | ✅ | ✅ | low | ✅ exported |
| `HandoffInputData` | ✅ | ✅ | high | ✅ exported |
| `HandoffResult` | ✅ | ✅ | low | ✅ exported |
| `ImageAgent` | ✅ | ✅ | high | ✅ exported |
| `MCPCall` | ✅ | ✅ | high | ✅ exported |
| `OCRAgent` | ✅ | ✅ | high | ✅ exported |
| `OCRConfig` | ✅ | ✅ | low | ✅ exported |
| `PromptExpanderAgent` | ✅ | ✅ | high | ✅ exported |
| `Provider` | ✅ | ✅ | high | ✅ exported |
| `QueryRewriterAgent` | ✅ | ✅ | high | ✅ exported |
| `RECOMMENDED\_PROMPT\_PREFIX` | ✅ | ✅ | low | ✅ exported |
| `RealtimeAgent` | ✅ | ✅ | high | ✅ exported |
| `RealtimeConfig` | ✅ | ✅ | low | ✅ exported |
| `ReasoningStep` | ✅ | ✅ | high | ✅ exported |
| `RewriteResult` | ✅ | ✅ | low | ✅ exported |
| `RewriteStrategy` | ✅ | ✅ | high | ✅ exported |
@@ -72,22 +86,14 @@
| `ToolResult` | ✅ | ✅ | low | ✅ exported |
| `ToolValidationError` | ✅ | ✅ | low | ✅ exported |
| `Tools` | ✅ | ✅ | high | ✅ exported |
| `VideoAgent` | ✅ | ✅ | high | ✅ exported |
| `VideoConfig` | ✅ | ✅ | low | ✅ exported |
| `VisionAgent` | ✅ | ✅ | high | ✅ exported |
| `VisionConfig` | ✅ | ✅ | low | ✅ exported |
| `WebSearchCall` | ✅ | ✅ | high | ✅ exported |
| `create\_context\_agent` | ✅ | ✅ | low | ✅ exported |
| `get\_registry` | ✅ | ✅ | low | ✅ exported |
| `get\_tool` | ✅ | ✅ | low | ✅ exported |
| `handoff` | ✅ | ✅ | low | ✅ exported |
| `handoff\_filters` | ✅ | ✅ | low | ✅ exported |
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ✅ exported |
| `register\_tool` | ✅ | ✅ | low | ✅ exported |
| `tool` | ✅ | ✅ | low | ✅ exported |
| `validate\_tool` | ✅ | ✅ | low | ✅ exported |
### P1_Persistence (20 exported, 5 missing)
### P1_Persistence (16 exported, 4 stub, 5 missing)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -96,16 +102,17 @@
| `YAMLWorkflowParser` | ✅ | ❌ | high | ⏳ missing |
| `if\_` | ✅ | ❌ | low | ⏳ missing |
| `include` | ✅ | ❌ | low | ⏳ missing |
| `If` | ✅ | ✅ | high | ⚠️ stub exported |
| `Parallel` | ✅ | ✅ | high | ⚠️ stub exported |
| `Route` | ✅ | ✅ | high | ⚠️ stub exported |
| `when` | ✅ | ✅ | low | ⚠️ stub exported |
| `AgentFlow` | ✅ | ✅ | high | ✅ exported |
| `Chunking` | ✅ | ✅ | high | ✅ exported |
| `If` | ✅ | ✅ | high | ✅ exported |
| `Knowledge` | ✅ | ✅ | high | ✅ exported |
| `Loop` | ✅ | ✅ | high | ✅ exported |
| `Memory` | ✅ | ✅ | high | ✅ exported |
| `Parallel` | ✅ | ✅ | high | ✅ exported |
| `Pipeline` | ✅ | ✅ | high | ✅ exported |
| `Repeat` | ✅ | ✅ | high | ✅ exported |
| `Route` | ✅ | ✅ | high | ✅ exported |
| `Session` | ✅ | ✅ | high | ✅ exported |
| `StepResult` | ✅ | ✅ | low | ✅ exported |
| `Workflow` | ✅ | ✅ | high | ✅ exported |
@@ -115,9 +122,8 @@
| `parallel` | ✅ | ✅ | low | ✅ exported |
| `repeat` | ✅ | ✅ | low | ✅ exported |
| `route` | ✅ | ✅ | low | ✅ exported |
| `when` | ✅ | ✅ | low | ✅ exported |
### P2_CLI (41 exported, 6 missing)
### P2_CLI (34 exported, 7 stub, 6 missing)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -127,12 +133,18 @@
| `load\_skill` | ✅ | ❌ | low | ⏳ missing |
| `validate` | ✅ | ❌ | low | ⏳ missing |
| `validate\_metadata` | ✅ | ❌ | low | ⏳ missing |
| `ContextPack` | ✅ | ✅ | high | ⚠️ stub exported |
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ⚠️ stub exported |
| `disable\_performance\_mode` | ✅ | ✅ | low | ⚠️ stub exported |
| `disable\_telemetry` | ✅ | ✅ | low | ⚠️ stub exported |
| `enable\_performance\_mode` | ✅ | ✅ | low | ⚠️ stub exported |
| `enable\_telemetry` | ✅ | ✅ | low | ⚠️ stub exported |
| `get\_telemetry` | ✅ | ✅ | low | ⚠️ stub exported |
| `ApprovalCallback` | ✅ | ✅ | high | ✅ exported |
| `Citation` | ✅ | ✅ | high | ✅ exported |
| `CitationsMode` | ✅ | ✅ | high | ✅ exported |
| `ContextConfig` | ✅ | ✅ | low | ✅ exported |
| `ContextManager` | ✅ | ✅ | high | ✅ exported |
| `ContextPack` | ✅ | ✅ | high | ✅ exported |
| `FastContext` | ✅ | ✅ | high | ✅ exported |
| `FastContextResult` | ✅ | ✅ | low | ✅ exported |
| `FileMatch` | ✅ | ✅ | high | ✅ exported |
@@ -162,14 +174,8 @@
| `TelemetryCollector` | ✅ | ✅ | high | ✅ exported |
| `TodoItem` | ✅ | ✅ | high | ✅ exported |
| `TodoList` | ✅ | ✅ | high | ✅ exported |
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ✅ exported |
| `disable\_performance\_mode` | ✅ | ✅ | low | ✅ exported |
| `disable\_telemetry` | ✅ | ✅ | low | ✅ exported |
| `enable\_performance\_mode` | ✅ | ✅ | low | ✅ exported |
| `enable\_telemetry` | ✅ | ✅ | low | ✅ exported |
| `get\_telemetry` | ✅ | ✅ | low | ✅ exported |
### P3_Advanced (182 exported, 103 missing)
### P3_Advanced (159 exported, 24 stub, 102 missing)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -236,7 +242,6 @@
| `RetryBackoffConfig` | ✅ | ❌ | low | ⏳ missing |
| `RulesConfig` | ✅ | ❌ | low | ⏳ missing |
| `RunOutcome` | ✅ | ❌ | high | ⏳ missing |
| `RunStatus` | ✅ | ❌ | high | ⏳ missing |
| `ScopeRequiredError` | ✅ | ❌ | low | ⏳ missing |
| `SendResult` | ✅ | ❌ | low | ⏳ missing |
| `SessionErrorEvent` | ✅ | ❌ | high | ⏳ missing |
@@ -276,6 +281,30 @@
| `termination\_to\_run\_status` | ✅ | ❌ | low | ⏳ missing |
| `unregister\_toolset` | ✅ | ❌ | low | ⏳ missing |
| `validate\_decision\_string` | ✅ | ❌ | low | ⏳ missing |
| `async\_display\_callbacks` | ✅ | ✅ | low | ⚠️ stub exported |
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ⚠️ stub exported |
| `discover\_plugins` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_error` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_generating` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_instruction` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_interaction` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_self\_reflection` | ✅ | ✅ | low | ⚠️ stub exported |
| `display\_tool\_call` | ✅ | ✅ | low | ⚠️ stub exported |
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ⚠️ stub exported |
| `error\_logs` | ✅ | ✅ | low | ⚠️ stub exported |
| `evaluate\_condition` | ✅ | ✅ | low | ⚠️ stub exported |
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ⚠️ stub exported |
| `get\_dimensions` | ✅ | ✅ | low | ⚠️ stub exported |
| `get\_plugin\_manager` | ✅ | ✅ | low | ⚠️ stub exported |
| `get\_plugin\_template` | ✅ | ✅ | low | ⚠️ stub exported |
| `load\_plugin` | ✅ | ✅ | low | ⚠️ stub exported |
| `parse\_plugin\_header` | ✅ | ✅ | low | ⚠️ stub exported |
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ⚠️ stub exported |
| `register\_display\_callback` | ✅ | ✅ | low | ⚠️ stub exported |
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ⚠️ stub exported |
| `sync\_display\_callbacks` | ✅ | ✅ | low | ⚠️ stub exported |
| `trace\_context` | ✅ | ✅ | low | ⚠️ stub exported |
| `track\_workflow` | ✅ | ✅ | low | ⚠️ stub exported |
| `A2A` | ✅ | ✅ | low | ✅ exported |
| `AGUI` | ✅ | ✅ | low | ✅ exported |
| `AUTONOMY\_PRESETS` | ✅ | ✅ | low | ✅ exported |
@@ -379,6 +408,7 @@
| `ReflectionOutput` | ✅ | ✅ | high | ✅ exported |
| `ResourceLimits` | ✅ | ✅ | high | ✅ exported |
| `RoutingConditionProtocol` | ✅ | ✅ | medium | ✅ exported |
| `RunStatus` | ✅ | ✅ | high | ✅ exported |
| `SandboxConfig` | ✅ | ✅ | low | ✅ exported |
| `SandboxProtocol` | ✅ | ✅ | medium | ✅ exported |
| `SandboxResult` | ✅ | ✅ | low | ✅ exported |
@@ -400,47 +430,26 @@
| `aembedding` | ✅ | ✅ | low | ✅ exported |
| `aembeddings` | ✅ | ✅ | low | ✅ exported |
| `apply\_config\_defaults` | ✅ | ✅ | low | ✅ exported |
| `async\_display\_callbacks` | ✅ | ✅ | low | ✅ exported |
| `clean\_triple\_backticks` | ✅ | ✅ | low | ✅ exported |
| `config` | ✅ | ✅ | low | ✅ exported |
| `detect\_url\_scheme` | ✅ | ✅ | low | ✅ exported |
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ✅ exported |
| `discover\_plugins` | ✅ | ✅ | low | ✅ exported |
| `display\_error` | ✅ | ✅ | low | ✅ exported |
| `display\_generating` | ✅ | ✅ | low | ✅ exported |
| `display\_instruction` | ✅ | ✅ | low | ✅ exported |
| `display\_interaction` | ✅ | ✅ | low | ✅ exported |
| `display\_self\_reflection` | ✅ | ✅ | low | ✅ exported |
| `display\_tool\_call` | ✅ | ✅ | low | ✅ exported |
| `embed` | ✅ | ✅ | low | ✅ exported |
| `embedding` | ✅ | ✅ | low | ✅ exported |
| `embeddings` | ✅ | ✅ | low | ✅ exported |
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ✅ exported |
| `error\_logs` | ✅ | ✅ | low | ✅ exported |
| `evaluate\_condition` | ✅ | ✅ | low | ✅ exported |
| `get\_config` | ✅ | ✅ | low | ✅ exported |
| `get\_config\_path` | ✅ | ✅ | low | ✅ exported |
| `get\_default` | ✅ | ✅ | low | ✅ exported |
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ✅ exported |
| `get\_defaults\_config` | ✅ | ✅ | low | ✅ exported |
| `get\_dimensions` | ✅ | ✅ | low | ✅ exported |
| `get\_plugin\_manager` | ✅ | ✅ | low | ✅ exported |
| `get\_plugin\_template` | ✅ | ✅ | low | ✅ exported |
| `get\_plugins\_config` | ✅ | ✅ | low | ✅ exported |
| `is\_path\_like` | ✅ | ✅ | low | ✅ exported |
| `is\_policy\_string` | ✅ | ✅ | low | ✅ exported |
| `load\_plugin` | ✅ | ✅ | low | ✅ exported |
| `memory` | ✅ | ✅ | low | ✅ exported |
| `parse\_plugin\_header` | ✅ | ✅ | low | ✅ exported |
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ✅ exported |
| `parse\_policy\_string` | ✅ | ✅ | low | ✅ exported |
| `register\_display\_callback` | ✅ | ✅ | low | ✅ exported |
| `resolve` | ✅ | ✅ | low | ✅ exported |
| `resolve\_autonomy` | ✅ | ✅ | low | ✅ exported |
| `resolve\_caching` | ✅ | ✅ | low | ✅ exported |
| `resolve\_context` | ✅ | ✅ | low | ✅ exported |
| `resolve\_execution` | ✅ | ✅ | low | ✅ exported |
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ✅ exported |
| `resolve\_guardrails` | ✅ | ✅ | low | ✅ exported |
| `resolve\_hooks` | ✅ | ✅ | low | ✅ exported |
| `resolve\_knowledge` | ✅ | ✅ | low | ✅ exported |
@@ -452,16 +461,13 @@
| `resolve\_skills` | ✅ | ✅ | low | ✅ exported |
| `resolve\_web` | ✅ | ✅ | low | ✅ exported |
| `suggest\_similar` | ✅ | ✅ | low | ✅ exported |
| `sync\_display\_callbacks` | ✅ | ✅ | low | ✅ exported |
| `tools` | ✅ | ✅ | low | ✅ exported |
| `trace\_context` | ✅ | ✅ | low | ✅ exported |
| `track\_workflow` | ✅ | ✅ | low | ✅ exported |
| `validate\_config` | ✅ | ✅ | low | ✅ exported |
| `workflows` | ✅ | ✅ | low | ✅ exported |
## Python Core SDK Exports
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents`
**Path:** `src/praisonai-agents/praisonaiagents`
<details>
<summary><strong>agent</strong> (43 exports)</summary>
@@ -726,7 +732,7 @@ from praisonaiagents import AgentFlow, If, Include, Loop, MAX_NESTING_DEPTH, Par
## TypeScript SDK Exports
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-ts/src`
**Path:** `src/praisonai-ts/src`
<details>
<summary><strong>agent</strong> (68 exports)</summary>
@@ -773,6 +779,15 @@ import { ActionDecision, ActionRequest, ActionType, AiderAgent, ApprovalPolicy,
</details>
<details>
<summary><strong>context</strong> (38 exports)</summary>
```typescript
import { BudgetAllocation, ContextBudget, ContextBudgeter, ContextBudgeterAllocation, ContextBudgeterConfig, ContextConfig, ContextItem, ContextLedger, ContextManager, ContextManagerConfig... } from 'praisonai';
```
</details>
<details>
<summary><strong>db</strong> (26 exports)</summary>
@@ -801,10 +816,10 @@ import { AgentEventBus, AgentEvents, Event, EventEmitterPubSub, EventHandler, Pu
</details>
<details>
<summary><strong>guardrails</strong> (4 exports)</summary>
<summary><strong>guardrails</strong> (16 exports)</summary>
```typescript
import { LLMGuardrail, LLMGuardrailConfig, LLMGuardrailResult, createLLMGuardrail } from 'praisonai';
import { Guardrail, GuardrailConfig, GuardrailContext, GuardrailFunction, GuardrailManager, GuardrailResult, GuardrailStatus, GuardrailValidationResult, LLMGuardrail, LLMGuardrailConfig... } from 'praisonai';
```
</details>
@@ -828,19 +843,19 @@ import { BaseObservabilityProvider, BaseVectorStore, BaseVoiceProvider, ChromaVe
</details>
<details>
<summary><strong>knowledge</strong> (16 exports)</summary>
<summary><strong>knowledge</strong> (61 exports)</summary>
```typescript
import { BaseReranker, CohereReranker, CrossEncoderReranker, GraphEdge, GraphNode, GraphQueryResult, GraphRAG, GraphRAGConfig, GraphStore, LLMReranker... } from 'praisonai';
import { BaseKnowledgeBase, BaseReranker, ChonkieAdapter, ChonkieChunk, ChonkieConfig, ChonkieStrategy, Chunk, ChunkStrategy, Chunking, ChunkingConfig... } from 'praisonai';
```
</details>
<details>
<summary><strong>llm</strong> (44 exports)</summary>
<summary><strong>llm</strong> (67 exports)</summary>
```typescript
import { ADAPTERS, AISDK_PROVIDERS, AdapterInfo, AnthropicProvider, BaseProvider, COMMUNITY_PROVIDERS, CommunityProvider, CreateProviderOptions, GenerateObjectOptions, GenerateObjectResult... } from 'praisonai';
import { ADAPTERS, AISDK_PROVIDERS, AdapterInfo, AnthropicProvider, BackendResolutionResult, BackendSource, BaseLLM, BaseProvider, COMMUNITY_PROVIDERS, CommunityProvider... } from 'praisonai';
```
</details>
@@ -908,6 +923,15 @@ import { ApprovalCallback, ApprovalCallbackConfig, Plan, PlanConfig, PlanResult,
</details>
<details>
<summary><strong>process</strong> (3 exports)</summary>
```typescript
import { BaseProcess, Process, ProcessConfig } from 'praisonai';
```
</details>
<details>
<summary><strong>protocols</strong> (30 exports)</summary>
@@ -917,6 +941,15 @@ import { A2A, A2AAgentCapabilities, A2AAgentCard, A2AAgentSkill, A2AArtifact, A2
</details>
<details>
<summary><strong>session</strong> (32 exports)</summary>
```typescript
import { EnhancedSession, EnhancedSessionConfig, FileSessionStore, HierarchicalSession, HierarchicalSessionConfig, ISessionStore, MemorySessionStore, Message, Run, RunConfig... } from 'praisonai';
```
</details>
<details>
<summary><strong>skills</strong> (10 exports)</summary>
@@ -945,10 +978,10 @@ import { AgentStats, AgentTelemetry, MetricEntry, MinimalTelemetry, PerformanceM
</details>
<details>
<summary><strong>tools</strong> (89 exports)</summary>
<summary><strong>tools</strong> (95 exports)</summary>
```typescript
import { BaseTool, BudgetExceededError, DelegatorConfig, FunctionTool, InstallHints, MissingDependencyError, MissingEnvVarError, PraisonTool, RedactionHooks, RegisteredTool... } from 'praisonai';
import { ArxivDownloadTool, ArxivPaper, ArxivSearchTool, BaseTool, BudgetExceededError, DelegatorConfig, FunctionTool, InstallHints, MCP, MCPTool... } from 'praisonai';
```
</details>
+512
View File
@@ -0,0 +1,512 @@
# Signature Parity: praisonaiagents (Python) vs praisonai-ts (TypeScript)
<!-- Generated by `python -m praisonai._dev.parity.signatures --write`. Do not edit by hand. -->
## What this measures
For each curated surface in `surface.yaml`, every Python parameter (positional and
keyword-only; `*args`/`**kwargs` excluded) is looked up on the TypeScript side
as an interface member or method parameter. Matching order: **exact** name,
**camelCase** (`snake_to_camel`), **alias** (`rules.yaml`), **flattened** (a nested
Python config exposed as several TS fields), else **missing**. For matched pairs the
checker compares required-ness and the effective default (TS constructor
fallbacks such as `config.x ?? v` count as defaults). Type classes are compared
as a warning only. Every gap must be waived in `waivers.yaml` or the check fails.
This complements `PARITY.md`, which only tracks whether an export exists.
## Summary
| Surface | Params | Exact | camelCase | Alias | Flattened | Missing | Mismatches | Waived | TS-only / TS total |
|---|---|---|---|---|---|---|---|---|---|
| `Agent.__init__` | 42 | 8 | 1 | 4 | 2 | 27 | 6 | 33 | 13 / 30 |
| `AgentTeam.__init__` | 23 | 3 | 0 | 0 | 0 | 20 | 1 | 21 | 2 / 5 |
| `Task.__init__` | 60 | 4 | 0 | 2 | 0 | 54 | 3 | 57 | 0 / 5 |
| `Agent.start` | 1 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | 4 / 5 |
| `Agent.chat` | 17 | 1 | 0 | 1 | 0 | 15 | 0 | 15 | 1 / 3 |
| `AgentTeam.start` | 3 | 0 | 0 | 0 | 0 | 3 | 0 | 3 | 0 / 0 |
| `Handoff.__init__` | 7 | 1 | 1 | 3 | 0 | 2 | 2 | 4 | 11 / 16 |
| `LLM.__init__` | 25 | 2 | 2 | 1 | 0 | 20 | 0 | 20 | 0 / 5 |
| `Session.__init__` | 7 | 1 | 4 | 2 | 0 | 0 | 2 | 2 | 2 / 9 |
| `tool()` | 11 | 2 | 0 | 1 | 0 | 8 | 3 | 11 | 2 / 5 |
| **Total (10 surfaces)** | 196 | 23 | 8 | 14 | 2 | 149 | 18 | 167 | 35 / 83 |
## Surfaces
### `Agent.__init__`
- Python: `src/praisonai-agents/praisonaiagents/agent/agent.py:583`
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:99` (ctor `src/praisonai-ts/src/agent/simple.ts:295`)
- Counts: 42 python params: 8 exact, 1 camelCase, 4 alias, 2 flattened, 27 missing; 6 mismatches; 33 waived; 13 TS-only of 30
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `name` | positional | null | `Optional[str]` | exact | `name` | ``Agent_${Math.random().toString(36).substr(2, 9)}`` | `string` | default mismatch (waived) |
| `role` | positional | null | `Optional[str]` | exact | `role` | `config.goal \|\| config.backstory` | `string` | default mismatch (waived) |
| `goal` | positional | null | `Optional[str]` | exact | `goal` | undefined | `string` | ok |
| `backstory` | positional | null | `Optional[str]` | exact | `backstory` | undefined | `string` | ok |
| `instructions` | positional | null | `Optional[str]` | exact | `instructions` | *required* | `string` | required mismatch (waived) |
| `llm` | positional | null | `Optional[Union[str, Any]]` | exact | `llm` | `getEnv('OPENAI_MODEL_NAME') \|\| getEnv('PRAISONAI_MODEL') \|\| 'gpt-4o-mini'` | `string` | default mismatch (waived) |
| `model` | positional | null | `Optional[Union[str, Any]]` | alias | `llm` | `getEnv('OPENAI_MODEL_NAME') \|\| getEnv('PRAISONAI_MODEL') \|\| 'gpt-4o-mini'` | `string` | default mismatch (waived) |
| `base_url` | positional | null | `Optional[str]` | alias | `baseURL` | undefined | `string` | ok |
| `api_key` | positional | null | `Optional[str]` | camelCase | `apiKey` | undefined | `string` | ok |
| `auth` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `tools` | positional | null | `Optional[List[Any]]` | exact | `tools` | undefined | `any[] \| Function[]` | ok |
| `toolsets` | positional | null | `Optional[List[str]]` | missing | | | | MISSING (waived) |
| `handoffs` | keyword | null | `Optional[List[Union['Agent', 'Handoff']]]` | missing | | | | MISSING (waived) |
| `memory` | keyword | null | `Optional[Union[bool, str, 'MemoryConfig', Any]]` | missing | | | | MISSING (waived) |
| `knowledge` | keyword | null | `Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']]` | missing | | | | MISSING (waived) |
| `planning` | keyword | false | `Optional[Union[bool, str, 'PlanningConfig']]` | missing | | | | MISSING (waived) |
| `reflection` | keyword | null | `Optional[Union[bool, str, 'ReflectionConfig']]` | missing | | | | MISSING (waived) |
| `rules` | keyword | null | `Optional[Union[bool, 'RulesConfig']]` | missing | | | | MISSING (waived) |
| `guardrails` | keyword | null | `Optional[Union[bool, str, Callable, 'GuardrailConfig']]` | missing | | | | MISSING (waived) |
| `web` | keyword | null | `Optional[Union[bool, str, 'WebConfig']]` | missing | | | | MISSING (waived) |
| `context` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']]` | missing | | | | MISSING (waived) |
| `autonomy` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'AutonomyConfig']]` | missing | | | | MISSING (waived) |
| `output` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'OutputConfig']]` | flattened | `verbose, markdown, stream` | | | ok |
| `execution` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'ExecutionConfig']]` | flattened | `maxIterations, maxToolCallsPerTurn` | | | ok |
| `templates` | keyword | null | `Optional[Union[Dict[str, Any], 'TemplateConfig']]` | missing | | | | MISSING (waived) |
| `caching` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']]` | alias | `cache` | false | `boolean` | default mismatch (waived) |
| `hooks` | keyword | null | `Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']]` | missing | | | | MISSING (waived) |
| `skills` | keyword | null | `Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']]` | missing | | | | MISSING (waived) |
| `self_improve` | keyword | false | `Optional[Union[bool, str, 'SkillReviewProtocol']]` | missing | | | | MISSING (waived) |
| `approval` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']]` | exact | `approval` | undefined | `boolean \| ApprovalManager` | ok |
| `tool_config` | keyword | null | `Optional[Union[bool, 'ToolConfig']]` | missing | | | | MISSING (waived) |
| `learn` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']]` | missing | | | | MISSING (waived) |
| `backend` | keyword | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `run_on` | keyword | null | `Optional[Union['ManagedRuntime', str]]` | missing | | | | MISSING (waived) |
| `tools_run_on` | keyword | null | `Optional[Union['ToolPlace', str, Any]]` | missing | | | | MISSING (waived) |
| `runtime` | keyword | null | `Optional[Union[bool, str, Dict[str, Any], 'AgentRuntimeConfig', 'RuntimeConfig']]` | missing | | | | MISSING (waived) |
| `interrupt_controller` | keyword | null | `Optional['InterruptController']` | alias | `signal` | undefined | `AbortSignal` | ok |
| `tool_search` | keyword | false | `Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']]` | missing | | | | MISSING (waived) |
| `message_steering` | keyword | false | `Optional[Union[bool, 'MessageSteeringProtocol']]` | missing | | | | MISSING (waived) |
| `sandbox` | keyword | null | `Optional[Union[bool, 'SandboxConfig']]` | missing | | | | MISSING (waived) |
| `retry` | keyword | null | `Optional[Union[bool, Dict[str, Any], 'RetryBackoffConfig']]` | missing | | | | MISSING (waived) |
| `reasoning_effort` | keyword | null | `Optional[str]` | missing | | | | MISSING (waived) |
TS-only members: `pretty`?, `fetch`?, `outputSchema`?, `outputSchemaName`?, `toolFunctions`?, `db`?, `sessionId`?, `runId`?, `historyLimit`?, `autoRestore`?, `autoPersist`?, `cacheTTL`?, `telemetry`?
### `AgentTeam.__init__`
- Python: `src/praisonai-agents/praisonaiagents/agents/agents.py:648`
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:1612` (ctor `src/praisonai-ts/src/agent/simple.ts:1669`)
- Python aliases: PraisonAIAgents, Agents
- Counts: 23 python params: 3 exact, 0 camelCase, 0 alias, 0 flattened, 20 missing; 1 mismatches; 21 waived; 2 TS-only of 5
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `agents` | positional | *required* | | exact | `agents` | *required* | `Agent[]` | ok |
| `tasks` | positional | null | | exact | `tasks` | undefined | `string[]` | ok |
| `process` | positional | "sequential" | | exact | `process` | undefined | `'sequential' \| 'parallel'` | default mismatch (waived) |
| `manager_llm` | positional | null | | missing | | | | MISSING (waived) |
| `name` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `variables` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `llm` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `model` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `memory` | positional | false | `Optional[Any]` | missing | | | | MISSING (waived) |
| `planning` | positional | false | `Optional[Any]` | missing | | | | MISSING (waived) |
| `context` | positional | false | `Optional[Any]` | missing | | | | MISSING (waived) |
| `output` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `execution` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `hooks` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `autonomy` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `knowledge` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `guardrails` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `web` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `reflection` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `caching` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `learn` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `tools_run_on` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `run_on` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
TS-only members: `verbose`?, `pretty`?
### `Task.__init__`
- Python: `src/praisonai-agents/praisonaiagents/task/task.py:47`
- TypeScript: `src/praisonai-ts/src/agent/types.ts:4` (ctor `src/praisonai-ts/src/agent/types.ts:20`)
- Counts: 60 python params: 4 exact, 0 camelCase, 2 alias, 0 flattened, 54 missing; 3 mismatches; 57 waived; 0 TS-only of 5
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `description` | positional | null | `Optional[str]` | exact | `description` | *required* | `string` | required mismatch (waived) |
| `expected_output` | positional | null | `Optional[str]` | exact | `expected_output` | *required* | `string` | required mismatch (waived) |
| `agent` | positional | null | `Optional[Agent]` | exact | `agent` | null | `any` | ok |
| `name` | positional | null | `Optional[str]` | exact | `name` | *required* | `string` | required mismatch (waived) |
| `tools` | positional | null | `Optional[List[Any]]` | missing | | | | MISSING (waived) |
| `context` | positional | null | `Optional[List[Union[str, List, 'Task']]]` | alias | `dependencies` | [] | `Task[]` | ok |
| `depends_on` | positional | null | `Optional[List[Union[str, List, 'Task']]]` | alias | `dependencies` | [] | `Task[]` | ok |
| `async_execution` | positional | false | `Optional[bool]` | missing | | | | MISSING (waived) |
| `config` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `output_file` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `output_json` | positional | null | `Optional[Type[BaseModel]]` | missing | | | | MISSING (waived) |
| `output_pydantic` | positional | null | `Optional[Type[BaseModel]]` | missing | | | | MISSING (waived) |
| `callback` | positional | null | `Optional[Union[Callable[[TaskOutput], Any], Callable[[TaskOutput], Coroutine[Any, Any, Any]]]]` | missing | | | | MISSING (waived) |
| `on_task_complete` | positional | null | `Optional[Union[Callable[[TaskOutput], Any], Callable[[TaskOutput], Coroutine[Any, Any, Any]]]]` | missing | | | | MISSING (waived) |
| `status` | positional | "not started" | `str` | missing | | | | MISSING (waived) |
| `result` | positional | null | `Optional[TaskOutput]` | missing | | | | MISSING (waived) |
| `create_directory` | positional | false | `Optional[bool]` | missing | | | | MISSING (waived) |
| `id` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `images` | positional | null | `Optional[List[str]]` | missing | | | | MISSING (waived) |
| `next_tasks` | positional | null | `Optional[List[str]]` | missing | | | | MISSING (waived) |
| `task_type` | positional | "task" | `str` | missing | | | | MISSING (waived) |
| `condition` | positional | null | `Optional[Dict[str, List[str]]]` | missing | | | | MISSING (waived) |
| `is_start` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `loop_state` | positional | null | `Optional[Dict[str, Union[str, int]]]` | missing | | | | MISSING (waived) |
| `memory` | positional | null | | missing | | | | MISSING (waived) |
| `quality_check` | positional | true | | missing | | | | MISSING (waived) |
| `input_file` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `rerun` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `retain_full_context` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `guardrail` | positional | null | `Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]]` | missing | | | | MISSING (waived) |
| `guardrails` | positional | null | `Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]]` | missing | | | | MISSING (waived) |
| `max_retries` | positional | 3 | `int` | missing | | | | MISSING (waived) |
| `retry_count` | positional | 0 | `int` | missing | | | | MISSING (waived) |
| `agent_config` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `variables` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `skip_on_failure` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `retry_delay` | positional | 0.0 | `float` | missing | | | | MISSING (waived) |
| `on_error` | positional | "stop" | `str` | missing | | | | MISSING (waived) |
| `action` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `handler` | positional | null | `Optional[Callable]` | missing | | | | MISSING (waived) |
| `should_run` | positional | null | `Optional[Callable]` | missing | | | | MISSING (waived) |
| `loop_over` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `loop_var` | positional | "item" | `str` | missing | | | | MISSING (waived) |
| `execution` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `routing` | positional | null | `Optional[Dict[str, List[str]]]` | missing | | | | MISSING (waived) |
| `output_config` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `output` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `when` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `then_task` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `else_task` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `autonomy` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `knowledge` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `web` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `reflection` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `planning` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `hooks` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `caching` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `output_variable` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `fail_on_callback_error` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `fail_on_memory_error` | positional | false | `bool` | missing | | | | MISSING (waived) |
TS-only members: none
### `Agent.start`
- Python: `src/praisonai-agents/praisonaiagents/agent/execution_mixin.py:831`
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:812`
- Counts: 1 python params: 1 exact, 0 camelCase, 0 alias, 0 flattened, 0 missing; 1 mismatches; 1 waived; 4 TS-only of 5
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `prompt` | positional | null | `Optional[str]` | exact | `prompt` | *required* | `string` | required mismatch (waived) |
TS-only members: `previousResult`?, `onToken`?, `signal`?, `onEvent`?
### `Agent.chat`
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3056`
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:1310`
- Counts: 17 python params: 1 exact, 0 camelCase, 1 alias, 0 flattened, 15 missing; 0 mismatches; 15 waived; 1 TS-only of 3
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `prompt` | positional | *required* | `str` | exact | `prompt` | *required* | `string` | ok |
| `temperature` | positional | null | `Optional[float]` | missing | | | | MISSING (waived) |
| `tools` | positional | null | `Optional[List[Any]]` | missing | | | | MISSING (waived) |
| `output_json` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `output_pydantic` | positional | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `reasoning_steps` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `stream` | positional | null | `Optional[bool]` | missing | | | | MISSING (waived) |
| `task_name` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `task_description` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `task_id` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `config` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `force_retrieval` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `skip_retrieval` | positional | false | `bool` | missing | | | | MISSING (waived) |
| `attachments` | positional | null | `Optional[List[str]]` | missing | | | | MISSING (waived) |
| `tool_choice` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `seed` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `cancel_token` | positional | null | `Optional[Any]` | alias | `signal` | undefined | `AbortSignal` | ok |
TS-only members: `previousResult`?
### `AgentTeam.start`
- Python: `src/praisonai-agents/praisonaiagents/agents/agents.py:2007`
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:1719`
- Counts: 3 python params: 0 exact, 0 camelCase, 0 alias, 0 flattened, 3 missing; 0 mismatches; 3 waived; 0 TS-only of 0
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `content` | positional | null | | missing | | | | MISSING (waived) |
| `return_dict` | positional | false | | missing | | | | MISSING (waived) |
| `output` | positional | null | | missing | | | | MISSING (waived) |
TS-only members: none
### `Handoff.__init__`
- Python: `src/praisonai-agents/praisonaiagents/agent/handoff.py:320`
- TypeScript: `src/praisonai-ts/src/agent/handoff.ts:156` (ctor `src/praisonai-ts/src/agent/handoff.ts:222`)
- Counts: 7 python params: 1 exact, 1 camelCase, 3 alias, 0 flattened, 2 missing; 2 mismatches; 4 waived; 11 TS-only of 16
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `agent` | positional | *required* | `'Agent'` | exact | `agent` | *required* | `EnhancedAgent` | ok |
| `tool_name_override` | positional | null | `Optional[str]` | alias | `name` | ``handoff_to_${config.agent.name}`` | `string` | default mismatch (waived) |
| `tool_description_override` | positional | null | `Optional[str]` | alias | `description` | ``Transfer conversation to ${config.agent.name}`` | `string` | default mismatch (waived) |
| `on_handoff` | positional | null | `Optional[Callable]` | camelCase | `onHandoff` | undefined | `(context: HandoffContext) => void \| Promise<void>` | ok |
| `input_type` | positional | null | `Optional[type]` | missing | | | | MISSING (waived) |
| `input_filter` | positional | null | `Optional[Union[Callable[[HandoffInputData], HandoffInputData], List[Callable[[HandoffInputData], HandoffInputData]]]]` | alias | `transformContext` | undefined | `(messages: any[]) => any[]` | ok (type differs) |
| `config` | positional | null | `Optional[HandoffConfig]` | missing | | | | MISSING (waived) |
TS-only members: `condition`?, `contextPolicy`?, `maxContextTokens`?, `maxContextMessages`?, `preserveSystem`?, `timeoutSeconds`?, `maxConcurrent`?, `detectCycles`?, `maxDepth`?, `onComplete`?, `onError`?
### `LLM.__init__`
- Python: `src/praisonai-agents/praisonaiagents/llm/llm.py:413`
- TypeScript: `src/praisonai-ts/src/llm/index.ts:1` (ctor `src/praisonai-ts/src/llm/index.ts:28`)
- Counts: 25 python params: 2 exact, 2 camelCase, 1 alias, 0 flattened, 20 missing; 0 mismatches; 20 waived; 0 TS-only of 5
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `model` | positional | *required* | `str` | exact | `model` | *required* | `string` | ok |
| `timeout` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `temperature` | positional | null | `Optional[float]` | exact | `temperature` | undefined | `number` | ok |
| `top_p` | positional | null | `Optional[float]` | missing | | | | MISSING (waived) |
| `n` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `max_tokens` | positional | null | `Optional[int]` | camelCase | `maxTokens` | undefined | `number` | ok |
| `presence_penalty` | positional | null | `Optional[float]` | missing | | | | MISSING (waived) |
| `frequency_penalty` | positional | null | `Optional[float]` | missing | | | | MISSING (waived) |
| `logit_bias` | positional | null | `Optional[Dict[int, float]]` | missing | | | | MISSING (waived) |
| `response_format` | positional | null | `Optional[Dict[str, Any]]` | missing | | | | MISSING (waived) |
| `seed` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `logprobs` | positional | null | `Optional[bool]` | missing | | | | MISSING (waived) |
| `top_logprobs` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
| `api_version` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `stop_phrases` | positional | null | `Optional[Union[str, List[str]]]` | missing | | | | MISSING (waived) |
| `api_key` | positional | null | `Optional[str]` | camelCase | `apiKey` | undefined | `string` | ok |
| `base_url` | positional | null | `Optional[str]` | alias | `baseURL` | undefined | `string` | ok |
| `events` | positional | [] | `List[Any]` | missing | | | | MISSING (waived) |
| `web_search` | positional | null | `Optional[Union[bool, Dict[str, Any]]]` | missing | | | | MISSING (waived) |
| `web_fetch` | positional | null | `Optional[Union[bool, Dict[str, Any]]]` | missing | | | | MISSING (waived) |
| `prompt_caching` | positional | null | `Optional[bool]` | missing | | | | MISSING (waived) |
| `claude_memory` | positional | null | `Optional[Union[bool, Any]]` | missing | | | | MISSING (waived) |
| `failover_manager` | positional | null | `Optional[FailoverManagerProtocol]` | missing | | | | MISSING (waived) |
| `auth` | positional | null | `Optional[str]` | missing | | | | MISSING (waived) |
| `max_iter` | positional | null | `Optional[int]` | missing | | | | MISSING (waived) |
TS-only members: none
### `Session.__init__`
- Python: `src/praisonai-agents/praisonaiagents/session/api.py:66`
- TypeScript: `src/praisonai-ts/src/session/session.ts:29` (ctor `src/praisonai-ts/src/session/session.ts:120`)
- Counts: 7 python params: 1 exact, 4 camelCase, 2 alias, 0 flattened, 0 missing; 2 mismatches; 2 waived; 2 TS-only of 9
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `session_id` | positional | null | `Optional[str]` | alias | `id` | `randomUUID().slice(0, 8)` | `string` | default mismatch (waived) |
| `user_id` | positional | null | `Optional[str]` | camelCase | `userId` | "default_user" | `string` | default mismatch (waived) |
| `agent_url` | positional | null | `Optional[str]` | camelCase | `agentUrl` | undefined | `string` | ok |
| `memory_config` | positional | null | `Optional[Dict[str, Any]]` | camelCase | `memoryConfig` | undefined | `Record<string, any>` | ok |
| `knowledge_config` | positional | null | `Optional[Dict[str, Any]]` | camelCase | `knowledgeConfig` | undefined | `Record<string, any>` | ok |
| `timeout` | positional | 30 | `int` | exact | `timeout` | 30 | `number` | ok |
| `session_ttl` | positional | null | `Optional[int]` | alias | `ttl` | undefined | `number` | ok |
TS-only members: `parent`?, `db`?
### `tool()`
- Python: `src/praisonai-agents/praisonaiagents/tools/decorator.py:235`
- TypeScript: `src/praisonai-ts/src/tools/decorator.ts:16` (ctor `src/praisonai-ts/src/tools/decorator.ts:48`)
- Counts: 11 python params: 2 exact, 0 camelCase, 1 alias, 0 flattened, 8 missing; 3 mismatches; 11 waived; 2 TS-only of 5
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|---|---|---|---|---|---|---|---|---|
| `func` | positional | null | `Optional[Callable]` | alias | `execute` | *required* | `(params: TParams, context?: ToolContext) => Promise<TResult> \| TResult` | required mismatch (waived) |
| `name` | keyword | null | `Optional[str]` | exact | `name` | *required* | `string` | required mismatch (waived) |
| `description` | keyword | null | `Optional[str]` | exact | `description` | ``Function ${config.name}`` | `string` | default mismatch (waived) |
| `version` | keyword | "1.0.0" | `str` | missing | | | | MISSING (waived) |
| `availability` | keyword | null | `Optional[Callable[[], tuple[bool, str]]]` | missing | | | | MISSING (waived) |
| `dynamic_schema_overrides` | keyword | null | `Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]` | missing | | | | MISSING (waived) |
| `retry_policy` | keyword | null | `Optional[Any]` | missing | | | | MISSING (waived) |
| `approval` | keyword | null | `Optional[Union[bool, str]]` | missing | | | | MISSING (waived) |
| `requires_approval` | keyword | `_UNSET` | `Union[bool, str]` | missing | | | | MISSING (waived) |
| `to_model_output` | keyword | null | `Optional[Callable[[Any], Any]]` | missing | | | | MISSING (waived) |
| `restart_safe` | keyword | null | `Optional[bool]` | missing | | | | MISSING (waived) |
TS-only members: `parameters`?, `category`?
## Active waivers
| Key | Reason | Owner | Issue | Expires |
|---|---|---|---|---|
| `Agent.__init__.auth` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.autonomy` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.backend` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.caching` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.context` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.guardrails` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.handoffs` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.hooks` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.instructions` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.knowledge` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.learn` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.llm` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.memory` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.message_steering` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.model` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.name` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.planning` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.reasoning_effort` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.reflection` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.retry` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.role` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.rules` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.run_on` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.runtime` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.sandbox` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.self_improve` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.skills` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.templates` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.tool_config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.tool_search` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.tools_run_on` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.toolsets` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.__init__.web` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.attachments` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.force_retrieval` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.output_json` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.output_pydantic` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.reasoning_steps` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.seed` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.skip_retrieval` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.stream` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.task_description` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.task_id` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.task_name` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.temperature` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.tool_choice` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.chat.tools` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Agent.start.prompt` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.autonomy` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.caching` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.context` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.execution` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.guardrails` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.hooks` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.knowledge` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.learn` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.llm` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.manager_llm` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.memory` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.model` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.name` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.output` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.planning` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.process` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.reflection` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.run_on` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.tools_run_on` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.variables` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.__init__.web` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.start.content` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.start.output` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `AgentTeam.start.return_dict` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Handoff.__init__.config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Handoff.__init__.input_type` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Handoff.__init__.tool_description_override` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Handoff.__init__.tool_name_override` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.api_version` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.auth` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.claude_memory` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.events` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.failover_manager` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.frequency_penalty` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.logit_bias` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.logprobs` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.max_iter` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.n` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.presence_penalty` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.prompt_caching` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.response_format` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.seed` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.stop_phrases` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.timeout` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.top_logprobs` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.top_p` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.web_fetch` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `LLM.__init__.web_search` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Session.__init__.session_id` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Session.__init__.user_id` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.action` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.agent_config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.async_execution` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.autonomy` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.caching` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.callback` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.condition` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.create_directory` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.description` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.else_task` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.execution` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.expected_output` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.fail_on_callback_error` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.fail_on_memory_error` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.guardrail` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.guardrails` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.handler` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.hooks` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.id` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.images` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.input_file` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.is_start` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.knowledge` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.loop_over` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.loop_state` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.loop_var` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.max_retries` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.memory` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.name` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.next_tasks` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.on_error` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.on_task_complete` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output_config` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output_file` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output_json` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output_pydantic` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.output_variable` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.planning` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.quality_check` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.reflection` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.rerun` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.result` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.retain_full_context` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.retry_count` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.retry_delay` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.routing` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.should_run` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.skip_on_failure` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.status` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.task_type` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.then_task` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.tools` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.variables` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.web` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `Task.__init__.when` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().approval` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().availability` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().description` | TS derives a default description from the tool name; Python leaves it None and infers from the docstring | praisonai-ts | | |
| `tool().dynamic_schema_overrides` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().func` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().name` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().requires_approval` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().restart_safe` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().retry_policy` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().to_model_output` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
| `tool().version` | baseline 2026-09-02: not yet ported to TypeScript | praisonai-ts | | |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
# Python ↔ TypeScript parity tooling
Two layers, both with Python (`src/praisonai-agents/praisonaiagents`) as the source of truth.
| Layer | Question it answers | Generated files | Config |
|---|---|---|---|
| **Names** (`generator.py`) | Is a Python export name present in the TS barrel? | `src/praisonai-ts/PARITY.md`, `FEATURE_PARITY_TRACKER.json` (+ Rust pair) | `python_extractor.py` / `typescript_extractor.py` mapping tables |
| **Signatures** (`signatures/`) | Do the parameters of a curated surface match, after naming rules? | `src/praisonai-ts/SIGNATURE_PARITY.md`, `signature-parity.json` | `signatures/surface.yaml`, `rules.yaml`, `waivers.yaml` |
A name row marked `⚠️ stub exported` means the only TS provider is `src/praisonai-ts/src/parity/index.ts`,
the shim written to satisfy name matching. Treat it as missing.
## Commands
```bash
# Names layer
python3 src/praisonai/scripts/generate_parity_tracker.py # regenerate
python3 src/praisonai/scripts/generate_parity_tracker.py --check # exit 1 on drift
# Signatures layer (needs node + the `typescript` package; point at any node_modules that has it)
export PARITY_TS_NODE_MODULES=src/praisonai-ts/node_modules
export PYTHONPATH=src/praisonai
python3 -m praisonai._dev.parity.signatures --write # regenerate report
python3 -m praisonai._dev.parity.signatures --check # exit 1 on drift or un-waived gap, 2 on tooling failure
python3 -m praisonai._dev.parity.signatures --diff Agent.__init__ # one surface, side by side
python3 -m praisonai._dev.parity.signatures --baseline # add waivers for every current un-waived gap
python3 -m praisonai._dev.parity.signatures --prune # delete waivers whose gap has been closed
```
## Keeping the SDKs in sync
1. Add or change a parameter on the Python side.
2. Run `--diff <surface>`. The row shows the canonical TS name the convention expects
(snake_case → camelCase unless `rules.yaml` names an alias or a flattening) and the default to carry over.
3. Port it in `src/praisonai-ts`, or add a waiver with a `reason` and `owner` to `waivers.yaml`.
A method that takes an options object (`chat(prompt, options?: AgentChatOptions)`) counts the
interface's members as parameters, so Python keyword arguments map onto it one to one.
4. Run `--write` and commit the regenerated report with the change.
CI (`.github/workflows/parity-gate.yml`) runs both `--check` modes on pull requests and fails on:
un-waived drift, a waiver that has expired, a waiver whose gap no longer exists, or a run that compared nothing.
`update-parity-tracker.yml` still regenerates the names layer on push to main.
## Adding a surface
Append an entry to `signatures/surface.yaml` with the Python `file`/`class`/`function` and the TS
`file`/`kind` (`interface` or `method`)/`name`, run `--baseline` to waive its current gaps, then `--write`.
@@ -0,0 +1,64 @@
"""
Repository root discovery shared by the parity extractors and generator.
The parity package lives at ``<repo>/src/praisonai/praisonai/_dev/parity``, so
the repository root can be located by walking up from this file until a
directory containing both ``src/praisonai-agents`` and ``src/praisonai-ts`` is
found. The current working directory is tried as a secondary starting point so
that an installed copy of the package still works when run from inside a
checkout. No machine-specific fallback exists: if neither walk succeeds a
``RuntimeError`` is raised asking the caller to pass ``--repo-root``.
"""
from pathlib import Path
from typing import Iterable, Optional
# Directories that must both exist under a candidate root for it to count.
_ROOT_MARKERS = ("src/praisonai-agents", "src/praisonai-ts")
def _is_repo_root(candidate: Path) -> bool:
return all((candidate / marker).is_dir() for marker in _ROOT_MARKERS)
def _walk_up(start: Path) -> Iterable[Path]:
current = start.resolve()
yield current
while current != current.parent:
current = current.parent
yield current
def find_repo_root(start: Optional[Path] = None) -> Path:
"""
Locate the monorepo root.
Args:
start: Optional directory to begin walking up from. Defaults to the
directory containing this module; the current working directory is
tried afterwards.
Returns:
The first ancestor containing both ``src/praisonai-agents`` and
``src/praisonai-ts``.
Raises:
RuntimeError: If no such directory exists on either walk.
"""
starts = []
if start is not None:
starts.append(Path(start))
starts.append(Path(__file__).parent)
starts.append(Path.cwd())
for origin in starts:
for candidate in _walk_up(origin):
if _is_repo_root(candidate):
return candidate
tried = ", ".join(str(Path(s).resolve()) for s in starts)
raise RuntimeError(
"Could not locate the PraisonAI repository root (a directory containing "
"both src/praisonai-agents and src/praisonai-ts). Walked up from: "
f"{tried}. Pass the root explicitly with --repo-root <path>."
)
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from ._paths import find_repo_root
@dataclass
class PythonExport:
@@ -108,13 +110,8 @@ class PythonFeatureExtractor:
self.wrapper_pkg = self.repo_root / "src" / "praisonai" / "praisonai"
def _find_repo_root(self) -> Path:
"""Find repository root by looking for .git directory."""
current = Path.cwd()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return Path("/Users/praison/praisonai-package")
"""Find the monorepo root (see ``_paths.find_repo_root``)."""
return find_repo_root()
def extract(self) -> PythonFeatures:
"""Extract all features from Python SDK."""
@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set
from ._paths import find_repo_root
# Rust reserved keywords and module conflicts that cannot be directly exported
# These are marked as N/A in parity tracking
@@ -102,13 +104,8 @@ class RustFeatureExtractor:
self.rust_pkg = self.repo_root / "src" / "praisonai-rust" / "praisonai"
def _find_repo_root(self) -> Path:
"""Find repository root by looking for .git directory."""
current = Path.cwd()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return Path("/Users/praison/praisonai-package")
"""Find the monorepo root (see ``_paths.find_repo_root``)."""
return find_repo_root()
def extract(self) -> RustFeatures:
"""Extract all features from Rust SDK."""
@@ -215,7 +212,7 @@ class RustFeatureExtractor:
"""Scan module files for public exports not in lib.rs."""
existing_exports = {e.name for e in features.exports}
for rs_file in src_dir.rglob("*.rs"):
for rs_file in sorted(src_dir.rglob("*.rs")): # sorted: first-seen wins, so order must not depend on the OS
if rs_file.name == "lib.rs":
continue
@@ -0,0 +1,58 @@
"""
Signature-level parity checker: praisonaiagents (Python) vs praisonai-ts.
The name-level tracker (``praisonai._dev.parity.generator``) answers "does the
export exist?". This package answers "does it take the same parameters, with
the same required-ness and defaults?" for a curated list of surfaces
(``surface.yaml``), using normalisation rules (``rules.yaml``) and an explicit,
reviewed waiver list (``waivers.yaml``).
Usage::
python -m praisonai._dev.parity.signatures --write # regenerate outputs
python -m praisonai._dev.parity.signatures --check # CI gate
python -m praisonai._dev.parity.signatures --diff Agent.__init__
python -m praisonai._dev.parity.signatures --baseline # waive current gaps
"""
from .schema import Param, SurfaceSignature, snake_to_camel
from .py_extract import extract_python_surface, python_type_class, PythonSurfaceNotFound
from .compare import (
Rules,
Waiver,
SurfaceComparison,
Evaluation,
ToolingError,
compare_surface,
compare_all,
evaluate,
render_markdown,
render_json,
load_rules,
load_surfaces,
load_waivers,
main,
)
__all__ = [
'Param',
'SurfaceSignature',
'snake_to_camel',
'extract_python_surface',
'python_type_class',
'PythonSurfaceNotFound',
'Rules',
'Waiver',
'SurfaceComparison',
'Evaluation',
'ToolingError',
'compare_surface',
'compare_all',
'evaluate',
'render_markdown',
'render_json',
'load_rules',
'load_surfaces',
'load_waivers',
'main',
]
@@ -0,0 +1,7 @@
"""Entry point: ``python -m praisonai._dev.parity.signatures``."""
import sys
from .compare import main
sys.exit(main())
@@ -0,0 +1,782 @@
"""
Comparator and CLI for the signature parity checker.
Pure functions (``compare_all``, ``evaluate``, ``render_markdown``,
``render_json``) work on pre-extracted :class:`SurfaceSignature` objects, so
they can be tested from JSON fixtures without Node or the TypeScript compiler.
``extract_all`` and :func:`main` wire in the real extractors.
Exit codes: 0 ok, 1 parity failure (gap, drift, waiver problem, nothing
checked), 2 tooling failure (node/typescript missing, surface not found).
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
import yaml
from .py_extract import (
DEFAULT_PYTHON_ROOT,
PythonSurfaceNotFound,
extract_python_surface,
)
from .schema import Param, SurfaceSignature, snake_to_camel
HERE = Path(__file__).resolve().parent
DEFAULT_SURFACE_FILE = HERE / 'surface.yaml'
DEFAULT_RULES_FILE = HERE / 'rules.yaml'
DEFAULT_WAIVERS_FILE = HERE / 'waivers.yaml'
TS_EXTRACTOR = HERE / 'ts_extract.mjs'
DEFAULT_TS_ROOT = 'src/praisonai-ts/src'
MD_OUTPUT = Path('src/praisonai-ts/SIGNATURE_PARITY.md')
JSON_OUTPUT = Path('src/praisonai-ts/signature-parity.json')
GENERATED_BY = 'praisonai._dev.parity.signatures'
BASELINE_REASON = 'baseline 2026-09-02: not yet ported to TypeScript'
BASELINE_OWNER = 'praisonai-ts'
MATCH_ORDER = ('exact', 'camelCase', 'alias', 'flattened', 'missing')
EXIT_OK = 0
EXIT_PARITY = 1
EXIT_TOOLING = 2
class ToolingError(Exception):
"""Something outside the code under test is broken (node, typescript, config)."""
# ------------------------------------------------------------------ configuration
@dataclass
class Surface:
"""One entry of ``surface.yaml``."""
key: str
python: Dict[str, Any]
typescript: Dict[str, Any]
def ts_target(self) -> Dict[str, Any]:
target = {'surface': self.key}
target.update(self.typescript)
return target
@dataclass
class SurfaceCatalogue:
surfaces: List[Surface]
python_root: str = DEFAULT_PYTHON_ROOT
typescript_root: str = DEFAULT_TS_ROOT
def get(self, key: str) -> Optional[Surface]:
for s in self.surfaces:
if s.key == key:
return s
return None
@dataclass
class Rules:
"""Contents of ``rules.yaml``."""
case: str = 'snake_to_camel'
aliases: Dict[str, Dict[str, str]] = field(default_factory=dict)
flattened: Dict[str, Dict[str, List[str]]] = field(default_factory=dict)
default_equivalences: List[Tuple[Any, Any]] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Rules':
data = data or {}
equivalences = []
for item in data.get('default_equivalences') or []:
equivalences.append((item.get('python'), item.get('typescript')))
return cls(
case=data.get('case', 'snake_to_camel'),
aliases={k: dict(v or {}) for k, v in (data.get('aliases') or {}).items()},
flattened={k: {p: list(f) for p, f in (v or {}).items()}
for k, v in (data.get('flattened') or {}).items()},
default_equivalences=equivalences,
)
def canonical(self, python_name: str) -> str:
if self.case == 'snake_to_camel':
return snake_to_camel(python_name)
return python_name
@dataclass
class Waiver:
"""One entry of ``waivers.yaml``: ``"<surface>.<python param>"``."""
key: str
reason: str
owner: str
issue: Optional[str] = None
expires: Optional[date] = None
@classmethod
def from_dict(cls, key: str, data: Dict[str, Any]) -> 'Waiver':
data = data or {}
for required in ('reason', 'owner'):
if not data.get(required):
raise ToolingError(f'waiver "{key}" is missing required field "{required}"')
expires = data.get('expires')
if isinstance(expires, str):
expires = date.fromisoformat(expires)
return cls(
key=key,
reason=str(data['reason']),
owner=str(data['owner']),
issue=str(data['issue']) if data.get('issue') else None,
expires=expires,
)
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {'reason': self.reason, 'owner': self.owner}
if self.issue:
out['issue'] = self.issue
if self.expires:
out['expires'] = self.expires
return out
def expired(self, today: date) -> bool:
return self.expires is not None and today > self.expires
def load_surfaces(path: Optional[Path] = None) -> SurfaceCatalogue:
path = path or DEFAULT_SURFACE_FILE
data = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
surfaces = []
for item in data.get('surfaces') or []:
if not item.get('key') or not item.get('python') or not item.get('typescript'):
raise ToolingError(f'surface.yaml entry is missing key/python/typescript: {item}')
surfaces.append(Surface(key=item['key'], python=dict(item['python']),
typescript=dict(item['typescript'])))
return SurfaceCatalogue(
surfaces=surfaces,
python_root=data.get('python_root', DEFAULT_PYTHON_ROOT),
typescript_root=data.get('typescript_root', DEFAULT_TS_ROOT),
)
def load_rules(path: Optional[Path] = None) -> Rules:
path = path or DEFAULT_RULES_FILE
return Rules.from_dict(yaml.safe_load(path.read_text(encoding='utf-8')) or {})
def load_waivers(path: Optional[Path] = None) -> List[Waiver]:
path = path or DEFAULT_WAIVERS_FILE
if not path.is_file():
return []
data = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
entries = data.get('waivers') or {}
if not isinstance(entries, dict):
raise ToolingError('waivers.yaml: "waivers" must be a mapping keyed "<surface>.<param>"')
return [Waiver.from_dict(str(k), v) for k, v in sorted(entries.items())]
def write_waivers(path: Path, waivers: Iterable[Waiver]) -> None:
header = (
'# Signature parity waivers.\n'
'#\n'
'# Key: "<surface key>.<python parameter>". Required: reason, owner.\n'
'# Optional: issue, expires (ISO date; an expired waiver FAILS the check).\n'
'# A waiver whose gap no longer exists is stale and FAILS the check: delete it.\n'
'# Regenerate missing entries with: python -m praisonai._dev.parity.signatures --baseline\n'
'\n'
)
body = {'waivers': {w.key: w.to_dict() for w in sorted(waivers, key=lambda w: w.key)}}
text = yaml.safe_dump(body, sort_keys=True, allow_unicode=True, default_flow_style=False, width=120)
path.write_text(header + text, encoding='utf-8')
# ------------------------------------------------------------------- comparison
@dataclass
class Gap:
kind: str # 'missing' | 'required' | 'default'
detail: str
@dataclass
class ParamRow:
python: Param
match: str # one of MATCH_ORDER
ts: Optional[Param] = None # matched TS member (None for flattened/missing)
ts_names: List[str] = field(default_factory=list)
gap: Optional[Gap] = None
warnings: List[str] = field(default_factory=list)
waived: Optional[Waiver] = None
@property
def ts_name(self) -> str:
if self.ts is not None:
return self.ts.name
return ', '.join(self.ts_names) if self.ts_names else ''
@dataclass
class SurfaceComparison:
key: str
python: SurfaceSignature
typescript: SurfaceSignature
rows: List[ParamRow] = field(default_factory=list)
ts_only: List[Param] = field(default_factory=list)
def counts(self) -> Dict[str, int]:
counts = {m: 0 for m in MATCH_ORDER}
gaps = 0
waived = 0
for row in self.rows:
counts[row.match] += 1
if row.gap is not None and row.gap.kind != 'missing':
gaps += 1
if row.waived is not None:
waived += 1
return {
'params': len(self.rows),
'exact': counts['exact'],
'camelCase': counts['camelCase'],
'alias': counts['alias'],
'flattened': counts['flattened'],
'missing': counts['missing'],
'mismatches': gaps,
'waived': waived,
'ts_only': len(self.ts_only),
'ts_total': len(self.typescript.params),
}
def _ts_default_token(param: Param) -> Any:
"""The comparable form of a TS default: JSON literal, expr text, or 'undefined'."""
if param.default_kind is None:
return 'undefined'
return param.default
def defaults_equivalent(py: Param, ts: Param, rules: Rules) -> bool:
"""True when the Python default and the TS effective default mean the same thing."""
ts_token = _ts_default_token(ts)
py_value = py.default
if py.default_kind == 'literal' and ts.default_kind == 'literal':
if py_value == ts_token and type(py_value) is type(ts_token):
return True
elif py.default_kind == 'expr' and ts.default_kind == 'expr':
if py_value == ts_token:
return True
for py_eq, ts_eq in rules.default_equivalences:
if py.default_kind == 'literal' and py_value == py_eq and type(py_value) is type(py_eq):
if ts_token == ts_eq and type(ts_token) is type(ts_eq):
return True
return False
def _fmt_default(param: Optional[Param]) -> str:
if param is None:
return ''
if param.required:
return '*required*'
if param.default_kind is None:
return 'undefined'
if param.default_kind == 'literal':
return json.dumps(param.default)
return f'`{param.default}`'
def compare_surface(py: SurfaceSignature, ts: SurfaceSignature, rules: Rules) -> SurfaceComparison:
"""Classify every Python parameter against the TS members and detect gaps."""
ts_by_name: Dict[str, Param] = {p.name: p for p in ts.params if not p.variadic}
aliases = rules.aliases.get(py.surface, {})
flattened = rules.flattened.get(py.surface, {})
matched_ts: set = set()
rows: List[ParamRow] = []
for param in py.params:
if param.variadic:
continue
row = ParamRow(python=param, match='missing')
canonical = rules.canonical(param.name)
if param.name in ts_by_name:
row.match, row.ts = 'exact', ts_by_name[param.name]
elif canonical in ts_by_name:
row.match, row.ts = 'camelCase', ts_by_name[canonical]
elif aliases.get(param.name) in ts_by_name:
row.match, row.ts = 'alias', ts_by_name[aliases[param.name]]
elif param.name in flattened and all(f in ts_by_name for f in flattened[param.name]):
row.match, row.ts_names = 'flattened', list(flattened[param.name])
if row.ts is not None:
matched_ts.add(row.ts.name)
_detect_gaps(row, rules)
elif row.match == 'flattened':
matched_ts.update(row.ts_names)
else:
row.gap = Gap('missing', f'is missing from TypeScript (looked for `{canonical}`)')
rows.append(row)
ts_only = [p for p in ts.params if not p.variadic and p.name not in matched_ts]
return SurfaceComparison(key=py.surface, python=py, typescript=ts, rows=rows, ts_only=ts_only)
def _detect_gaps(row: ParamRow, rules: Rules) -> None:
py, ts = row.python, row.ts
assert ts is not None
if py.required != ts.required:
side = 'required in Python but optional in TypeScript' if py.required \
else 'optional in Python but required in TypeScript'
row.gap = Gap('required', f'is {side} (TS `{ts.name}`)')
elif not py.required and not defaults_equivalent(py, ts, rules):
row.gap = Gap(
'default',
f'default differs: python={_fmt_default(py)} typescript={_fmt_default(ts)} (TS `{ts.name}`)',
)
if py.type_class != 'unknown' and ts.type_class != 'unknown' and py.type_class != ts.type_class:
row.warnings.append(f'type differs: python {py.type_class} ({py.type_text}) vs typescript {ts.type_class} ({ts.type_text})')
def signatures_from_json(items: Iterable[Dict[str, Any]]) -> Dict[str, SurfaceSignature]:
"""Index a list of schema dicts by surface key."""
return {s['surface']: SurfaceSignature.from_dict(s) for s in items}
def compare_all(
python: Dict[str, SurfaceSignature],
typescript: Dict[str, SurfaceSignature],
rules: Rules,
keys: Optional[Sequence[str]] = None,
) -> List[SurfaceComparison]:
"""Compare every surface present on both sides (in ``keys`` order if given)."""
order = list(keys) if keys is not None else [k for k in python if k in typescript]
comparisons = []
for key in order:
if key not in python:
raise ToolingError(f'surface {key}: no Python extraction')
if key not in typescript:
raise ToolingError(f'surface {key}: no TypeScript extraction')
comparisons.append(compare_surface(python[key], typescript[key], rules))
return comparisons
# ------------------------------------------------------------------- evaluation
@dataclass
class Evaluation:
failures: List[str] = field(default_factory=list)
active_waivers: List[Waiver] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.failures
def evaluate(
comparisons: List[SurfaceComparison],
waivers: List[Waiver],
today: Optional[date] = None,
) -> Evaluation:
"""
Apply the gate rules. Mutates ``row.waived`` on covered rows.
a) every active waiver is reported; b) expired waiver fails; c) stale
waiver fails; d) un-waived missing/required/default gap fails; e) zero
surfaces or zero params on either side fails ("nothing was checked").
"""
today = today or date.today()
result = Evaluation()
if not comparisons:
result.failures.append('nothing was checked: zero surfaces were compared')
return result
py_total = sum(len(c.python.params) for c in comparisons)
ts_total = sum(len(c.typescript.params) for c in comparisons)
if py_total == 0:
result.failures.append('nothing was checked: the Python extractor produced zero parameters')
if ts_total == 0:
result.failures.append('nothing was checked: the TypeScript extractor produced zero parameters')
by_key = {w.key: w for w in waivers}
used: set = set()
for comparison in comparisons:
for row in comparison.rows:
for warning in row.warnings:
result.warnings.append(f'{comparison.key}: `{row.python.name}` {warning}')
if row.gap is None:
continue
key = f'{comparison.key}.{row.python.name}'
waiver = by_key.get(key)
if waiver is None:
result.failures.append(
f'{comparison.key}: parameter `{row.python.name}` {row.gap.detail} '
f'-- port it or add a waiver "{key}"'
)
continue
used.add(key)
row.waived = waiver
if waiver.expired(today):
result.failures.append(
f'{comparison.key}: waiver for parameter `{row.python.name}` expired on '
f'{waiver.expires.isoformat()} but the gap remains ({row.gap.detail}) '
f'-- port it or extend the waiver'
)
for waiver in waivers:
if waiver.key not in used:
result.failures.append(
f'stale waiver "{waiver.key}": the gap it covers no longer exists -- delete it from waivers.yaml'
)
result.active_waivers = [w for w in waivers if w.key in used and not w.expired(today)]
return result
# -------------------------------------------------------------------- rendering
def _md_escape(text: str) -> str:
return text.replace('|', '\\|')
def _status(row: ParamRow) -> str:
if row.gap is None:
return 'ok' if not row.warnings else 'ok (type differs)'
label = {'missing': 'MISSING', 'required': 'required mismatch', 'default': 'default mismatch'}[row.gap.kind]
return f'{label} (waived)' if row.waived else label
def render_surface_markdown(comparison: SurfaceComparison) -> str:
"""Side-by-side table for one surface."""
py, ts = comparison.python, comparison.typescript
lines = [f'### `{comparison.key}`', '']
ts_loc = f'`{ts.location}`'
if ts.extra.get('ctor_location'):
ts_loc += f' (ctor `{ts.extra["ctor_location"]}`)'
lines.append(f'- Python: `{py.location}`')
lines.append(f'- TypeScript: {ts_loc}')
if py.extra.get('aliases'):
lines.append(f'- Python aliases: {", ".join(py.extra["aliases"])}')
c = comparison.counts()
lines.append(
f'- Counts: {c["params"]} python params: {c["exact"]} exact, {c["camelCase"]} camelCase, '
f'{c["alias"]} alias, {c["flattened"]} flattened, {c["missing"]} missing; '
f'{c["mismatches"]} mismatches; {c["waived"]} waived; {c["ts_only"]} TS-only of {c["ts_total"]}'
)
lines.append('')
lines.append('| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |')
lines.append('|---|---|---|---|---|---|---|---|---|')
for row in comparison.rows:
p = row.python
lines.append('| ' + ' | '.join(_md_escape(x) for x in (
f'`{p.name}`', p.kind, _fmt_default(p), f'`{p.type_text}`' if p.type_text else '',
row.match, f'`{row.ts_name}`' if row.ts_name else '', _fmt_default(row.ts),
f'`{row.ts.type_text}`' if row.ts is not None and row.ts.type_text else '',
_status(row),
)) + ' |')
lines.append('')
if comparison.ts_only:
lines.append('TS-only members: ' + ', '.join(
f'`{p.name}`' + ('' if p.required else '?') for p in comparison.ts_only))
else:
lines.append('TS-only members: none')
lines.append('')
return '\n'.join(lines)
def totals(comparisons: List[SurfaceComparison]) -> Dict[str, int]:
keys = ['params', 'exact', 'camelCase', 'alias', 'flattened', 'missing', 'mismatches', 'waived', 'ts_only', 'ts_total']
out = {k: 0 for k in keys}
for c in comparisons:
for k, v in c.counts().items():
out[k] += v
out['surfaces'] = len(comparisons)
return out
def render_markdown(comparisons: List[SurfaceComparison], waivers: List[Waiver]) -> str:
"""Whole SIGNATURE_PARITY.md. Deterministic: no dates, relative paths only."""
lines = [
'# Signature Parity: praisonaiagents (Python) vs praisonai-ts (TypeScript)',
'',
f'<!-- Generated by `python -m {GENERATED_BY} --write`. Do not edit by hand. -->',
'',
'## What this measures',
'',
'For each curated surface in `surface.yaml`, every Python parameter (positional and',
'keyword-only; `*args`/`**kwargs` excluded) is looked up on the TypeScript side',
'as an interface member or method parameter. Matching order: **exact** name,',
'**camelCase** (`snake_to_camel`), **alias** (`rules.yaml`), **flattened** (a nested',
'Python config exposed as several TS fields), else **missing**. For matched pairs the',
'checker compares required-ness and the effective default (TS constructor',
'fallbacks such as `config.x ?? v` count as defaults). Type classes are compared',
'as a warning only. Every gap must be waived in `waivers.yaml` or the check fails.',
'This complements `PARITY.md`, which only tracks whether an export exists.',
'',
'## Summary',
'',
'| Surface | Params | Exact | camelCase | Alias | Flattened | Missing | Mismatches | Waived | TS-only / TS total |',
'|---|---|---|---|---|---|---|---|---|---|',
]
for c in comparisons:
n = c.counts()
lines.append(
f'| `{c.key}` | {n["params"]} | {n["exact"]} | {n["camelCase"]} | {n["alias"]} | {n["flattened"]} '
f'| {n["missing"]} | {n["mismatches"]} | {n["waived"]} | {n["ts_only"]} / {n["ts_total"]} |'
)
t = totals(comparisons)
lines.append(
f'| **Total ({t["surfaces"]} surfaces)** | {t["params"]} | {t["exact"]} | {t["camelCase"]} | {t["alias"]} '
f'| {t["flattened"]} | {t["missing"]} | {t["mismatches"]} | {t["waived"]} | {t["ts_only"]} / {t["ts_total"]} |'
)
lines += ['', '## Surfaces', '']
for c in comparisons:
lines.append(render_surface_markdown(c))
lines += ['## Active waivers', '']
if waivers:
lines.append('| Key | Reason | Owner | Issue | Expires |')
lines.append('|---|---|---|---|---|')
for w in sorted(waivers, key=lambda w: w.key):
lines.append('| ' + ' | '.join(_md_escape(x) for x in (
f'`{w.key}`', w.reason, w.owner, w.issue or '', w.expires.isoformat() if w.expires else '',
)) + ' |')
else:
lines.append('None.')
lines.append('')
return '\n'.join(lines)
def render_json(comparisons: List[SurfaceComparison], waivers: List[Waiver]) -> str:
"""Machine-readable output. Deterministic: sorted keys, no dates."""
surfaces: Dict[str, Any] = {}
for c in comparisons:
params = []
for row in c.rows:
entry = row.python.to_dict()
entry.update({
'match': row.match,
'ts_name': row.ts_name or None,
'ts': row.ts.to_dict() if row.ts is not None else None,
'gap': {'kind': row.gap.kind, 'detail': row.gap.detail} if row.gap else None,
'waived': row.waived.key if row.waived else None,
'warnings': list(row.warnings),
})
params.append(entry)
surfaces[c.key] = {
'python': {'location': c.python.location, **c.python.extra},
'typescript': {'location': c.typescript.location, **c.typescript.extra},
'counts': c.counts(),
'params': params,
'ts_only': [p.to_dict() for p in c.ts_only],
}
doc = {
'generatedBy': GENERATED_BY,
'surfaces': surfaces,
'totals': totals(comparisons),
'waivers': [
{'key': w.key, 'reason': w.reason, 'owner': w.owner, 'issue': w.issue,
'expires': w.expires.isoformat() if w.expires else None}
for w in sorted(waivers, key=lambda w: w.key)
],
}
return json.dumps(doc, indent=2, sort_keys=True) + '\n'
# ------------------------------------------------------------------- extraction
def find_repo_root(explicit: Optional[Path] = None) -> Path:
"""``--repo-root`` > nearest ``.git`` above cwd > nearest ``.git`` above this file."""
if explicit is not None:
root = Path(explicit).resolve()
if not (root / 'src').is_dir():
raise ToolingError(f'--repo-root {root} does not look like the monorepo (no src/)')
return root
for start in (Path.cwd(), HERE):
current = start.resolve()
while True:
if (current / '.git').exists() and (current / 'src').is_dir():
return current
if current.parent == current:
break
current = current.parent
raise ToolingError('cannot find the repository root; pass --repo-root')
def run_ts_extractor(repo_root: Path, targets: List[Dict[str, Any]],
ts_root: str = DEFAULT_TS_ROOT) -> List[Dict[str, Any]]:
"""Run ``ts_extract.mjs`` and return the parsed JSON list. Raises ToolingError."""
node = shutil.which('node')
if node is None:
raise ToolingError('node is not on PATH; the TypeScript extractor needs Node.js')
cmd = [node, str(TS_EXTRACTOR), '--repo-root', str(repo_root), '--ts-root', ts_root]
proc = subprocess.run(
cmd, input=json.dumps(targets), capture_output=True, text=True, env=os.environ.copy(),
)
if proc.returncode != 0:
raise ToolingError(
f'ts_extract.mjs exited {proc.returncode}:\n{proc.stderr.strip() or proc.stdout.strip()}'
)
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise ToolingError(f'ts_extract.mjs printed invalid JSON: {exc}\n{proc.stdout[:500]}')
if not isinstance(data, list):
raise ToolingError('ts_extract.mjs output is not a JSON array')
return data
def extract_all(
repo_root: Path,
catalogue: SurfaceCatalogue,
keys: Optional[Sequence[str]] = None,
) -> Tuple[Dict[str, SurfaceSignature], Dict[str, SurfaceSignature]]:
"""Extract both sides for every (or the selected) surface."""
surfaces = catalogue.surfaces if keys is None else [s for s in catalogue.surfaces if s.key in keys]
if keys is not None:
unknown = set(keys) - {s.key for s in surfaces}
if unknown:
raise ToolingError(f'unknown surface key(s): {", ".join(sorted(unknown))}')
python: Dict[str, SurfaceSignature] = {}
for s in surfaces:
try:
python[s.key] = extract_python_surface(repo_root, s.key, s.python, catalogue.python_root)
except (PythonSurfaceNotFound, SyntaxError, OSError) as exc:
raise ToolingError(str(exc))
ts_items = run_ts_extractor(repo_root, [s.ts_target() for s in surfaces], catalogue.typescript_root)
typescript = signatures_from_json(ts_items)
return python, typescript
def baseline_waivers(comparisons: List[SurfaceComparison], existing: List[Waiver],
reason: str = BASELINE_REASON, owner: str = BASELINE_OWNER) -> List[Waiver]:
"""Existing waivers plus one for every currently un-waived gap (never overwrites)."""
by_key = {w.key: w for w in existing}
for c in comparisons:
for row in c.rows:
if row.gap is None:
continue
key = f'{c.key}.{row.python.name}'
if key not in by_key:
by_key[key] = Waiver(key=key, reason=reason, owner=owner)
return [by_key[k] for k in sorted(by_key)]
# -------------------------------------------------------------------------- CLI
def prune_waivers(comparisons: List[SurfaceComparison], waivers: List[Waiver]) -> List[Waiver]:
"""Return the waivers whose gap still exists, dropping stale ones."""
live = {
f'{c.key}.{row.python.name}'
for c in comparisons for row in c.rows if row.gap is not None
}
return [w for w in waivers if w.key in live]
def _print_report(evaluation: Evaluation, comparisons: List[SurfaceComparison], out=None) -> None:
out = out or sys.stdout
print(f'Active waivers ({len(evaluation.active_waivers)}):', file=out)
for w in evaluation.active_waivers:
expires = f', expires {w.expires.isoformat()}' if w.expires else ''
issue = f', {w.issue}' if w.issue else ''
print(f' - {w.key}: {w.reason} [{w.owner}{issue}{expires}]', file=out)
t = totals(comparisons)
print(
f'Compared {t["surfaces"]} surfaces, {t["params"]} python params: {t["exact"]} exact, '
f'{t["camelCase"]} camelCase, {t["alias"]} alias, {t["flattened"]} flattened, '
f'{t["missing"]} missing, {t["mismatches"]} mismatches, {t["waived"]} waived; '
f'{t["ts_only"]} TS-only of {t["ts_total"]}.', file=out,
)
if evaluation.warnings:
print(f'Warnings ({len(evaluation.warnings)}):', file=out)
for w in evaluation.warnings:
print(f' - {w}', file=out)
if evaluation.failures:
print(f'FAILURES ({len(evaluation.failures)}):', file=out)
for f in evaluation.failures:
print(f' - {f}', file=out)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=f'python -m {GENERATED_BY}',
description='Signature-level Python <-> TypeScript parity checker.',
)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument('--write', action='store_true', help='write SIGNATURE_PARITY.md and signature-parity.json')
mode.add_argument('--check', action='store_true', help='fail on drift, gaps, or waiver problems (CI gate)')
mode.add_argument('--diff', metavar='SURFACE', help='print one surface table to stdout')
mode.add_argument('--baseline', action='store_true', help='add waivers for every current un-waived gap')
mode.add_argument('--prune', action='store_true', help='delete waivers whose gap no longer exists (stale)')
parser.add_argument('--repo-root', type=Path, default=None)
parser.add_argument('--today', type=date.fromisoformat, default=None, help=argparse.SUPPRESS)
return parser
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
try:
repo_root = find_repo_root(args.repo_root)
catalogue = load_surfaces()
rules = load_rules()
waivers = load_waivers()
if args.diff:
python, typescript = extract_all(repo_root, catalogue, keys=[args.diff])
comparison = compare_all(python, typescript, rules, keys=[args.diff])[0]
evaluate([comparison], [w for w in waivers if w.key.startswith(args.diff + '.')], args.today)
print(render_surface_markdown(comparison))
return EXIT_OK
python, typescript = extract_all(repo_root, catalogue)
comparisons = compare_all(python, typescript, rules, keys=[s.key for s in catalogue.surfaces])
if args.baseline:
before = len(waivers)
waivers = baseline_waivers(comparisons, waivers)
write_waivers(DEFAULT_WAIVERS_FILE, waivers)
print(f'waivers.yaml: {len(waivers) - before} added, {len(waivers)} total -> {DEFAULT_WAIVERS_FILE}')
return EXIT_OK
if args.prune:
before = len(waivers)
waivers = prune_waivers(comparisons, waivers)
write_waivers(DEFAULT_WAIVERS_FILE, waivers)
print(f'waivers.yaml: {before - len(waivers)} stale removed, {len(waivers)} remain -> {DEFAULT_WAIVERS_FILE}')
return EXIT_OK
evaluation = evaluate(comparisons, waivers, args.today)
md = render_markdown(comparisons, waivers)
js = render_json(comparisons, waivers)
md_path = repo_root / MD_OUTPUT
json_path = repo_root / JSON_OUTPUT
if args.write:
md_path.write_text(md, encoding='utf-8')
json_path.write_text(js, encoding='utf-8')
print(f'wrote {MD_OUTPUT} and {JSON_OUTPUT}')
else:
for path, content in ((md_path, md), (json_path, js)):
rel = path.relative_to(repo_root)
if not path.is_file():
evaluation.failures.append(f'{rel} does not exist -- run --write and commit it')
elif path.read_text(encoding='utf-8') != content:
evaluation.failures.append(f'{rel} is out of date -- run --write and commit the result')
_print_report(evaluation, comparisons)
if evaluation.ok:
print('signature parity: OK')
return EXIT_OK
sys.stdout.flush()
print('signature parity: FAILED', file=sys.stderr)
return EXIT_PARITY
except ToolingError as exc:
sys.stdout.flush()
print(f'signature parity: tooling error: {exc}', file=sys.stderr)
return EXIT_TOOLING
if __name__ == '__main__': # pragma: no cover
sys.exit(main())
@@ -0,0 +1,312 @@
"""
Python signature extractor for the signature parity checker.
Parses ``praisonaiagents`` sources with :mod:`ast` only -- it never imports the
package, so it needs no optional dependencies and cannot be skewed by runtime
monkey-patching. Output follows the shared schema in :mod:`.schema`.
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .schema import Param, SurfaceSignature, snake_to_camel, split_top_level
DEFAULT_PYTHON_ROOT = 'src/praisonai-agents/praisonaiagents'
class PythonSurfaceNotFound(Exception):
"""Raised when a surface listed in ``surface.yaml`` cannot be located."""
# --------------------------------------------------------------------------- types
_SIMPLE_TYPES = {
'str': 'string', 'bytes': 'string',
'int': 'number', 'float': 'number', 'complex': 'number',
'bool': 'boolean',
'dict': 'object', 'Dict': 'object', 'Mapping': 'object', 'MutableMapping': 'object',
'TypedDict': 'object', 'Type': 'object',
'list': 'array', 'List': 'array', 'Sequence': 'array', 'MutableSequence': 'array',
'set': 'array', 'Set': 'array', 'frozenset': 'array', 'FrozenSet': 'array',
'tuple': 'array', 'Tuple': 'array', 'Iterable': 'array', 'Iterator': 'array',
'Callable': 'callable', 'Coroutine': 'callable', 'Awaitable': 'callable',
'Any': 'unknown', 'object': 'unknown', 'None': 'unknown', 'NoneType': 'unknown',
}
def _union_members(text: str) -> List[str]:
"""Flatten ``Optional[X]``, ``Union[A, B]`` and ``A | B`` into member types."""
t = text.strip()
if t.startswith(('"', "'")) and t.endswith(('"', "'")) and len(t) >= 2:
# Forward reference written as a string annotation.
return _union_members(t[1:-1])
head, _, _ = t.partition('[')
head = head.rsplit('.', 1)[-1]
if head == 'Optional' and t.endswith(']'):
return _union_members(t[t.index('[') + 1:-1])
if head == 'Union' and t.endswith(']'):
inner = t[t.index('[') + 1:-1]
return [m for part in split_top_level(inner, ',') for m in _union_members(part)]
parts = split_top_level(t, '|')
if len(parts) > 1:
return [m for part in parts for m in _union_members(part)]
return [t]
def _single_type_class(text: str) -> str:
t = text.strip()
if not t:
return 'unknown'
head, _, _ = t.partition('[')
head = head.rsplit('.', 1)[-1].strip()
if head == 'Literal' and t.endswith(']'):
inner = split_top_level(t[t.index('[') + 1:-1], ',')
first = inner[0].strip() if inner else ''
if first.startswith(('"', "'")):
return 'string'
if first in ('True', 'False'):
return 'boolean'
if first[:1].isdigit() or first.startswith('-'):
return 'number'
return 'unknown'
if head in _SIMPLE_TYPES:
return _SIMPLE_TYPES[head]
if head[:1].isupper():
# A class reference (Agent, Memory, BaseModel, ...) is an object.
return 'object'
return 'unknown'
def python_type_class(annotation: str) -> str:
"""Map a Python annotation's source text onto the shared ``type_class`` set."""
if not annotation or not annotation.strip():
return 'unknown'
members = [m for m in _union_members(annotation) if m.strip() not in ('None', 'NoneType')]
if not members:
return 'unknown'
classes = {_single_type_class(m) for m in members}
if len(classes) == 1:
return classes.pop()
return 'union'
# ------------------------------------------------------------------------ defaults
def _json_safe(value: Any) -> Tuple[bool, Any]:
"""Return (ok, value) where value is JSON-serialisable (tuples/sets -> lists)."""
if value is None or isinstance(value, (bool, int, float, str)):
return True, value
if isinstance(value, (list, tuple, set, frozenset)):
items = []
for item in value:
ok, converted = _json_safe(item)
if not ok:
return False, None
items.append(converted)
return True, items
if isinstance(value, dict):
out = {}
for key, item in value.items():
if not isinstance(key, str):
return False, None
ok, converted = _json_safe(item)
if not ok:
return False, None
out[key] = converted
return True, out
return False, None
def normalise_default(node: Optional[ast.AST]) -> Tuple[Any, Optional[str]]:
"""
Turn a default-value AST node into ``(value, kind)``.
Literals become JSON values with kind ``"literal"``; anything else keeps its
source text with kind ``"expr"``. ``None`` node -> ``(None, None)``.
"""
if node is None:
return None, None
try:
value = ast.literal_eval(node)
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
return ast.unparse(node), 'expr'
ok, converted = _json_safe(value)
if ok:
return converted, 'literal'
return ast.unparse(node), 'expr'
# ----------------------------------------------------------------------- extraction
def _params_of(fn: ast.AST, in_class: bool) -> List[Param]:
args = fn.args
positional = list(args.posonlyargs) + list(args.args)
pos_defaults: List[Optional[ast.AST]] = (
[None] * (len(positional) - len(args.defaults)) + list(args.defaults)
)
out: List[Param] = []
for index, (arg, default) in enumerate(zip(positional, pos_defaults)):
if index == 0 and in_class and arg.arg in ('self', 'cls'):
continue
out.append(_make_param(arg, default, 'positional'))
if args.vararg is not None:
out.append(_make_param(args.vararg, None, 'var_positional'))
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
out.append(_make_param(arg, default, 'keyword'))
if args.kwarg is not None:
out.append(_make_param(args.kwarg, None, 'var_keyword'))
return out
def _make_param(arg: ast.arg, default: Optional[ast.AST], kind: str) -> Param:
type_text = ast.unparse(arg.annotation) if arg.annotation is not None else ''
value, default_kind = normalise_default(default)
required = default is None and kind in ('positional', 'keyword')
return Param(
name=arg.arg,
canonical=snake_to_camel(arg.arg),
kind=kind,
required=required,
default=value,
default_kind=default_kind,
type_text=type_text,
type_class=python_type_class(type_text),
)
def discover_class(tree: ast.Module, init_first_param: str) -> Optional[str]:
"""Return the name of the class whose ``__init__`` takes ``init_first_param`` first after self."""
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
for item in node.body:
if (
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name == '__init__'
and len(item.args.args) > 1
and item.args.args[1].arg == init_first_param
):
return node.name
return None
def _find_function(
tree: ast.Module, class_name: Optional[str], function: str,
) -> Tuple[Optional[ast.AST], Optional[str]]:
"""
Locate ``function``. With ``class_name`` look only inside that class; without
it, take the first module-level function or method (source order) with that
name. Closures nested inside function bodies are never considered.
"""
if class_name is not None:
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == class_name:
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == function:
return item, class_name
return None, None
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function:
return node, None
if isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == function:
return item, node.name
return None, None
def extract_python_surface(
repo_root: Path,
key: str,
spec: Dict[str, Any],
python_root: str = DEFAULT_PYTHON_ROOT,
) -> SurfaceSignature:
"""
Extract one Python surface.
``spec`` keys (from ``surface.yaml``):
- ``file``: path relative to ``python_root``
- ``function``: function/method name
- ``class`` (optional): enclosing class name
- ``discover`` (optional): ``{"init_first_param": "agents"}`` -- resolve the
class as the one whose ``__init__`` takes that parameter first
"""
rel_file = spec['file']
path = Path(repo_root) / python_root / rel_file
if not path.is_file():
raise PythonSurfaceNotFound(f'{key}: python file not found: {path}')
tree = ast.parse(path.read_text(encoding='utf-8'))
class_name: Optional[str] = spec.get('class')
discover = spec.get('discover') or {}
if class_name is None and discover.get('init_first_param'):
class_name = discover_class(tree, discover['init_first_param'])
if class_name is None:
raise PythonSurfaceNotFound(
f'{key}: no class in {rel_file} has __init__(self, {discover["init_first_param"]}, ...)'
)
fn, enclosing = _find_function(tree, class_name, spec['function'])
if fn is None:
where = f'{class_name}.' if class_name else ''
raise PythonSurfaceNotFound(f'{key}: {where}{spec["function"]} not found in {rel_file}')
location = f'{python_root}/{rel_file}:{fn.lineno}'
extra: Dict[str, Any] = {}
if enclosing:
extra['resolved_class'] = enclosing
if spec.get('aliases'):
extra['aliases'] = list(spec['aliases'])
return SurfaceSignature(
surface=key,
language='python',
location=location,
params=_params_of(fn, in_class=enclosing is not None),
extra=extra,
)
def extract_python_source(source: str, function: str, class_name: Optional[str] = None,
surface: str = 'fixture', location: str = '<string>') -> SurfaceSignature:
"""Extract from an in-memory source string (used by tests)."""
tree = ast.parse(source)
fn, enclosing = _find_function(tree, class_name, function)
if fn is None:
raise PythonSurfaceNotFound(f'{surface}: {function} not found')
return SurfaceSignature(
surface=surface,
language='python',
location=f'{location}:{fn.lineno}',
params=_params_of(fn, in_class=enclosing is not None),
extra={'resolved_class': enclosing} if enclosing else {},
)
def main(argv: Optional[List[str]] = None) -> int:
"""Debug helper: dump the Python side of every surface in surface.yaml as JSON."""
import argparse
import sys
from .compare import find_repo_root, load_surfaces
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--repo-root', type=Path, default=None)
args = parser.parse_args(argv)
repo_root = find_repo_root(args.repo_root)
catalogue = load_surfaces()
out = [
extract_python_surface(repo_root, s.key, s.python, catalogue.python_root).to_dict()
for s in catalogue.surfaces
]
json.dump(out, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write('\n')
return 0
if __name__ == '__main__': # pragma: no cover
raise SystemExit(main())
@@ -0,0 +1,51 @@
# Normalisation rules for the signature parity checker. Written once here so
# neither extractor nor the comparator hard-codes naming conventions.
# Applied automatically to every Python parameter before matching.
case: snake_to_camel
# Per-surface semantic aliases (python name -> typescript name) that are NOT a
# pure case change. Several Python names may map to one TS field.
aliases:
Agent.__init__:
base_url: baseURL
output_json: outputSchema
output_pydantic: outputSchema
interrupt_controller: signal
caching: cache
model: llm
Task.__init__:
context: dependencies
depends_on: dependencies
Handoff.__init__:
tool_name_override: name
tool_description_override: description
input_filter: transformContext
LLM.__init__:
base_url: baseURL
Session.__init__:
session_id: id
agent_url: agentUrl
session_ttl: ttl
tool():
func: execute
Agent.chat:
cancel_token: signal
# Python nested-config parameters that TypeScript exposes as top-level fields.
# A Python param counts as "flattened" when every listed TS field exists.
flattened:
Agent.__init__:
output: [verbose, markdown, stream]
execution: [maxIterations, maxToolCallsPerTurn]
caching: [cache, cacheTTL]
# Defaults that mean the same thing on both sides. `typescript: undefined`
# means "no default / not set in the constructor".
default_equivalences:
- {python: null, typescript: undefined}
# Python "None means empty" (`tools if tools else []`) vs a TS empty literal.
- {python: null, typescript: []}
- {python: null, typescript: {}}
- {python: true, typescript: true}
- {python: false, typescript: false}
@@ -0,0 +1,169 @@
"""
Shared schema for the signature-level parity checker.
Both extractors (``py_extract.py`` for Python, ``ts_extract.mjs`` for
TypeScript) emit the same JSON shape, and the comparator consumes it. Keeping
the shape in one place means a pre-extracted JSON fixture can be fed straight
into the comparator without either toolchain being present.
Schema (one object per surface)::
{
"surface": "<key from surface.yaml>",
"language": "python" | "typescript",
"location": "src/.../file.py:LINE", # repo-relative
"params": [
{
"name": "expected_output", # as written in source
"canonical": "expectedOutput", # camelCased (python) / as written (ts)
"kind": "positional" | "keyword" | "property"
| "var_positional" | "var_keyword",
"required": true | false,
"default": <JSON literal> | "<source text>" | null,
"default_kind": "literal" | "expr" | null,
"type_text": "Optional[str]",
"type_class": "string" | "number" | "boolean" | "object" | "array"
| "callable" | "union" | "unknown"
}
],
"extra": { ... } # e.g. ctor_location, resolved_class
}
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
TYPE_CLASSES = (
'string', 'number', 'boolean', 'object', 'array', 'callable', 'union', 'unknown',
)
PARAM_KINDS = ('positional', 'keyword', 'property', 'var_positional', 'var_keyword')
# Kinds that can never be matched by name against the other language.
VARIADIC_KINDS = ('var_positional', 'var_keyword')
@dataclass
class Param:
"""One parameter (Python) or one interface member / method parameter (TS)."""
name: str
canonical: str
kind: str
required: bool
default: Any = None
default_kind: Optional[str] = None # 'literal' | 'expr' | None (no default)
type_text: str = ''
type_class: str = 'unknown'
def to_dict(self) -> Dict[str, Any]:
return {
'name': self.name,
'canonical': self.canonical,
'kind': self.kind,
'required': self.required,
'default': self.default,
'default_kind': self.default_kind,
'type_text': self.type_text,
'type_class': self.type_class,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Param':
return cls(
name=data['name'],
canonical=data.get('canonical') or data['name'],
kind=data.get('kind', 'positional'),
required=bool(data.get('required', False)),
default=data.get('default'),
default_kind=data.get('default_kind'),
type_text=data.get('type_text', '') or '',
type_class=data.get('type_class', 'unknown') or 'unknown',
)
@property
def variadic(self) -> bool:
return self.kind in VARIADIC_KINDS
@dataclass
class SurfaceSignature:
"""The extracted signature of one surface in one language."""
surface: str
language: str
location: str
params: List[Param] = field(default_factory=list)
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
'surface': self.surface,
'language': self.language,
'location': self.location,
'params': [p.to_dict() for p in self.params],
'extra': dict(self.extra),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'SurfaceSignature':
return cls(
surface=data['surface'],
language=data['language'],
location=data.get('location', ''),
params=[Param.from_dict(p) for p in data.get('params', [])],
extra=dict(data.get('extra') or {}),
)
def snake_to_camel(name: str) -> str:
"""``expected_output`` -> ``expectedOutput``. Leading underscores are kept."""
stripped = name.lstrip('_')
prefix = name[: len(name) - len(stripped)]
parts = [p for p in stripped.split('_')]
if not parts:
return name
head, *rest = parts
return prefix + head + ''.join(p[:1].upper() + p[1:] for p in rest)
def split_top_level(text: str, sep: str) -> List[str]:
"""
Split ``text`` on ``sep`` only at bracket depth 0 and outside quotes.
Handles ``()``, ``[]``, ``{}`` and ``<>`` so that
``Dict[str, Any] | List[int]`` splits on ``|`` but not on the inner comma.
"""
parts: List[str] = []
depth = 0
quote: Optional[str] = None
current: List[str] = []
i = 0
while i < len(text):
ch = text[i]
if quote:
current.append(ch)
if ch == '\\' and i + 1 < len(text):
current.append(text[i + 1])
i += 1
elif ch == quote:
quote = None
elif ch in ('"', "'", '`'):
quote = ch
current.append(ch)
elif ch in '([{<':
depth += 1
current.append(ch)
elif ch in ')]}>':
depth = max(0, depth - 1)
current.append(ch)
elif depth == 0 and text.startswith(sep, i):
parts.append(''.join(current))
current = []
i += len(sep)
continue
else:
current.append(ch)
i += 1
parts.append(''.join(current))
return parts
@@ -0,0 +1,59 @@
# Curated surfaces compared by the signature parity checker.
#
# python.file is relative to python_root; typescript.file is relative to
# typescript_root. `discover.init_first_param` resolves a class by the first
# parameter of its __init__ instead of by name (agents.py exports the team
# class under several aliases).
#
# Run: python -m praisonai._dev.parity.signatures --check
python_root: src/praisonai-agents/praisonaiagents
typescript_root: src/praisonai-ts/src
surfaces:
- key: Agent.__init__
python: {file: agent/agent.py, class: Agent, function: __init__}
typescript: {file: agent/simple.ts, kind: interface, name: SimpleAgentConfig, ctorClass: Agent}
- key: AgentTeam.__init__
python:
file: agents/agents.py
discover: {init_first_param: agents}
aliases: [PraisonAIAgents, Agents]
function: __init__
typescript: {file: agent/simple.ts, kind: interface, name: AgentTeamConfig, ctorClass: AgentTeam}
- key: Task.__init__
python: {file: task/task.py, class: Task, function: __init__}
typescript: {file: agent/types.ts, kind: interface, name: TaskConfig, ctorClass: Task}
- key: Agent.start
python: {file: agent/execution_mixin.py, function: start}
typescript: {file: agent/simple.ts, kind: method, name: start, cls: Agent}
- key: Agent.chat
python: {file: agent/chat_mixin.py, function: chat}
typescript: {file: agent/simple.ts, kind: method, name: chat, cls: Agent}
- key: AgentTeam.start
python:
file: agents/agents.py
discover: {init_first_param: agents}
function: start
typescript: {file: agent/simple.ts, kind: method, name: start, cls: AgentTeam}
- key: Handoff.__init__
python: {file: agent/handoff.py, class: Handoff, function: __init__}
typescript: {file: agent/handoff.ts, kind: interface, name: HandoffConfig, ctorClass: Handoff}
- key: LLM.__init__
python: {file: llm/llm.py, class: LLM, function: __init__}
typescript: {file: llm/index.ts, kind: interface, name: LLMConfig, ctorClass: BaseLLM}
- key: Session.__init__
python: {file: session/api.py, class: Session, function: __init__}
typescript: {file: session/session.ts, kind: interface, name: SessionConfig, ctorClass: Session}
- key: tool()
python: {file: tools/decorator.py, function: tool}
typescript: {file: tools/decorator.ts, kind: interface, name: ToolConfig, ctorClass: FunctionTool}
@@ -0,0 +1,414 @@
#!/usr/bin/env node
// TypeScript signature extractor for the signature parity checker.
//
// Uses the TypeScript compiler API (no type-checker, no emit) to read interface
// members, constructor fallbacks (`config.x ?? v`, `config.x || v`, ternaries,
// destructuring defaults) and method parameters. Output follows the shared
// schema documented in schema.py.
//
// Usage:
// node ts_extract.mjs --repo-root <repo> [--ts-root src/praisonai-ts/src] [--targets '<json>' | < targets.json]
//
// A target: { "surface": key, "file": "agent/simple.ts", "kind": "interface"|"method",
// "name": "SimpleAgentConfig", "ctorClass": "Agent" (optional), "cls": "Agent" (optional) }
//
// `typescript` is resolved from PARITY_TS_NODE_MODULES when that env var is set
// (explicit override: if it does not resolve there we fail rather than silently
// falling back), else <repo>/src/praisonai-ts/node_modules, else normal Node
// resolution. On any tooling failure: message on stderr, exit 2, never an
// empty result with exit 0.
import { createRequire } from 'node:module';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const DEFAULT_TS_ROOT = 'src/praisonai-ts/src';
function fail(message) {
process.stderr.write(`ts_extract: ${message}\n`);
process.exit(2);
}
function parseArgs(argv) {
const opts = { repoRoot: null, tsRoot: DEFAULT_TS_ROOT, targets: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo-root') opts.repoRoot = argv[++i];
else if (a === '--ts-root') opts.tsRoot = argv[++i];
else if (a === '--targets') opts.targets = argv[++i];
else if (a.startsWith('--')) fail(`unknown option ${a}`);
else opts.targets = a;
}
if (!opts.repoRoot) fail('--repo-root is required');
return opts;
}
function loadTypescript(repoRoot) {
const require = createRequire(import.meta.url);
const tried = [];
const override = process.env.PARITY_TS_NODE_MODULES;
const candidates = override
? [path.join(override, 'typescript')]
: [path.join(repoRoot, 'src', 'praisonai-ts', 'node_modules', 'typescript'), 'typescript'];
for (const candidate of candidates) {
try {
const ts = require(candidate);
if (!ts || typeof ts.createSourceFile !== 'function') throw new Error('module has no createSourceFile');
return ts;
} catch (err) {
tried.push(`${candidate}: ${err && err.message ? err.message.split('\n')[0] : err}`);
}
}
fail(
`cannot load the "typescript" module.\n` +
` tried:\n ${tried.join('\n ')}\n` +
(override
? ` PARITY_TS_NODE_MODULES=${override} is set and does not contain typescript/.`
: ` Install praisonai-ts dependencies (pnpm install in src/praisonai-ts) or set PARITY_TS_NODE_MODULES.`)
);
}
function readTargets(opts) {
let text = opts.targets;
if (text == null) {
try {
text = readFileSync(0, 'utf8');
} catch (err) {
fail(`no targets given and stdin unreadable: ${err.message}`);
}
}
let targets;
try {
targets = JSON.parse(text);
} catch (err) {
fail(`targets are not valid JSON: ${err.message}`);
}
if (!Array.isArray(targets)) fail('targets must be a JSON array');
return targets;
}
// ------------------------------------------------------------------ type class
function splitTopLevel(text, sep) {
const parts = [];
let depth = 0;
let quote = null;
let current = '';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quote) {
current += ch;
if (ch === '\\' && i + 1 < text.length) { current += text[++i]; }
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'" || ch === '`') {
quote = ch; current += ch;
} else if ('([{<'.includes(ch)) {
depth++; current += ch;
} else if (')]}>'.includes(ch)) {
depth = Math.max(0, depth - 1); current += ch;
} else if (depth === 0 && text.startsWith(sep, i)) {
parts.push(current); current = ''; i += sep.length - 1;
} else {
current += ch;
}
}
parts.push(current);
return parts;
}
function hasTopLevelArrow(text) {
let depth = 0; let quote = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; continue; }
if (ch === '"' || ch === "'" || ch === '`') quote = ch;
else if ('([{<'.includes(ch)) depth++;
else if (')]}>'.includes(ch)) depth = Math.max(0, depth - 1);
else if (depth === 0 && text.startsWith('=>', i)) return true;
}
return false;
}
function singleTypeClass(raw) {
const t = raw.trim();
if (!t) return 'unknown';
if (hasTopLevelArrow(t) || t === 'Function') return 'callable';
if (/^['"`]/.test(t)) return 'string';
if (/^-?\d/.test(t)) return 'number';
if (t === 'true' || t === 'false') return 'boolean';
if (t === 'string' || t === 'symbol') return 'string';
if (t === 'number' || t === 'bigint') return 'number';
if (t === 'boolean') return 'boolean';
if (t === 'any' || t === 'unknown' || t === 'void' || t === 'never' || t === 'undefined' || t === 'null') return 'unknown';
if (t.endsWith('[]') || /^(Array|ReadonlyArray|Set|Iterable)</.test(t) || t.startsWith('[')) return 'array';
if (/^(Record|Map|Partial|Readonly|Pick|Omit|Promise)</.test(t) || t.startsWith('{') || t === 'object') return 'object';
if (/^[A-Z]/.test(t)) return 'object';
return 'unknown';
}
function typeClass(text) {
const t = (text || '').trim();
if (!t) return 'unknown';
if (hasTopLevelArrow(t)) return 'callable';
const members = splitTopLevel(t, '|').map((s) => s.trim()).filter((s) => s && s !== 'undefined' && s !== 'null');
if (members.length === 0) return 'unknown';
const classes = new Set(members.map(singleTypeClass));
return classes.size === 1 ? [...classes][0] : 'union';
}
// ------------------------------------------------------------------- defaults
function compact(text) { return text.replace(/\s+/g, ' ').trim(); }
function literalOf(ts, node, sf) {
if (!node) return { default: null, default_kind: null };
if (ts.isParenthesizedExpression(node)) return literalOf(ts, node.expression, sf);
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return { default: node.text, default_kind: 'literal' };
if (ts.isNumericLiteral(node)) return { default: Number(node.text), default_kind: 'literal' };
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand)) {
return { default: -Number(node.operand.text), default_kind: 'literal' };
}
if (node.kind === ts.SyntaxKind.TrueKeyword) return { default: true, default_kind: 'literal' };
if (node.kind === ts.SyntaxKind.FalseKeyword) return { default: false, default_kind: 'literal' };
if (node.kind === ts.SyntaxKind.NullKeyword) return { default: null, default_kind: 'literal' };
if (ts.isArrayLiteralExpression(node) && node.elements.length === 0) return { default: [], default_kind: 'literal' };
if (ts.isObjectLiteralExpression(node) && node.properties.length === 0) return { default: {}, default_kind: 'literal' };
return { default: compact(node.getText(sf)), default_kind: 'expr' };
}
function isConfigAccess(ts, node, paramName) {
return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === paramName;
}
// Flatten `a ?? b ?? c` / `a || b || c` into operands (left-assoc parse tree).
function flattenChain(ts, node, opKind) {
if (ts.isBinaryExpression(node) && node.operatorToken.kind === opKind) {
return [...flattenChain(ts, node.left, opKind), node.right];
}
return [node];
}
function collectCtorDefaults(ts, cls, sf) {
const ctor = cls.members.find((m) => ts.isConstructorDeclaration(m) && m.body);
if (!ctor) return { defaults: new Map(), line: null };
return collectDefaultsFrom(ts, ctor, sf, ctor.parameters[0]);
}
// Defaults read from `<param>.x ?? v`, `<param>.x || v`, ternaries on
// `<param>.x`, and destructuring `{ x = v } = <param>` inside a function body.
// Used for constructors (config object) and for methods that take an options
// object (see extractMethod), so both surfaces report defaults the same way.
function collectDefaultsFrom(ts, fnLike, sf, param) {
const ctor = fnLike;
if (!ctor || !ctor.body) return { defaults: new Map(), line: null };
const paramName = param && ts.isIdentifier(param.name) ? param.name.text : 'config';
const defaults = new Map();
const record = (name, value) => { if (!defaults.has(name)) defaults.set(name, value); };
const visit = (node) => {
if (ts.isBinaryExpression(node)) {
const kind = node.operatorToken.kind;
if (kind === ts.SyntaxKind.QuestionQuestionToken || kind === ts.SyntaxKind.BarBarToken) {
const operands = flattenChain(ts, node, kind);
if (isConfigAccess(ts, operands[0], paramName) && operands.length > 1) {
const name = operands[0].name.text;
if (operands.length === 2) record(name, literalOf(ts, operands[1], sf));
else record(name, { default: compact(operands.slice(1).map((o) => o.getText(sf)).join(kind === ts.SyntaxKind.QuestionQuestionToken ? ' ?? ' : ' || ')), default_kind: 'expr' });
}
}
} else if (ts.isConditionalExpression(node)) {
const cond = node.condition;
if (isConfigAccess(ts, cond, paramName)) {
record(cond.name.text, literalOf(ts, node.whenFalse, sf));
} else if (ts.isBinaryExpression(cond) && isConfigAccess(ts, cond.left, paramName)) {
const rhs = cond.right.getText(sf);
const op = cond.operatorToken.kind;
const isUndef = rhs === 'undefined' || rhs === 'null';
if (isUndef && (op === ts.SyntaxKind.ExclamationEqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken)) {
record(cond.left.name.text, literalOf(ts, node.whenFalse, sf));
} else if (isUndef && (op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsToken)) {
record(cond.left.name.text, literalOf(ts, node.whenTrue, sf));
}
}
} else if (ts.isVariableDeclaration(node) && node.initializer && ts.isIdentifier(node.initializer)
&& node.initializer.text === paramName && ts.isObjectBindingPattern(node.name)) {
for (const el of node.name.elements) {
const key = el.propertyName ? el.propertyName.getText(sf) : el.name.getText(sf);
if (el.initializer) record(key, literalOf(ts, el.initializer, sf));
}
}
ts.forEachChild(node, visit);
};
visit(ctor.body);
return { defaults, line: lineOf(sf, ctor) };
}
// ------------------------------------------------------------------ extraction
function lineOf(sf, node) { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; }
function findAll(ts, root, predicate) {
const found = [];
const visit = (n) => { if (predicate(n)) found.push(n); ts.forEachChild(n, visit); };
visit(root);
return found;
}
function enclosingClassName(ts, node) {
let p = node.parent;
while (p) { if (ts.isClassDeclaration(p)) return p.name ? p.name.text : null; p = p.parent; }
return null;
}
function extractInterface(ts, sf, target, location) {
const decl = findAll(ts, sf, (n) => ts.isInterfaceDeclaration(n) && n.name.text === target.name)[0];
if (!decl) return { error: `${target.surface}: interface ${target.name} not found in ${target.file}` };
let ctorInfo = { defaults: new Map(), line: null };
const extra = {};
if (target.ctorClass) {
const cls = findAll(ts, sf, (n) => ts.isClassDeclaration(n) && n.name && n.name.text === target.ctorClass)[0];
if (!cls) return { error: `${target.surface}: ctor class ${target.ctorClass} not found in ${target.file}` };
ctorInfo = collectCtorDefaults(ts, cls, sf);
if (ctorInfo.line) extra.ctor_location = `${location}:${ctorInfo.line}`;
}
if (decl.heritageClauses && decl.heritageClauses.length) {
extra.extends = decl.heritageClauses.flatMap((h) => h.types.map((t) => compact(t.getText(sf))));
}
const params = [];
for (const m of decl.members) {
if (!ts.isPropertySignature(m) && !ts.isMethodSignature(m)) continue;
const name = m.name.getText(sf);
const optional = !!m.questionToken;
const typeText = ts.isMethodSignature(m)
? compact(m.getText(sf).replace(/^[^(]*/, '')) // method signature -> its "(params): ret" part
: (m.type ? compact(m.type.getText(sf)) : 'any');
const d = ctorInfo.defaults.get(name) || { default: null, default_kind: null };
params.push({
name,
canonical: name,
kind: 'property',
required: !optional,
default: d.default,
default_kind: d.default_kind,
type_text: typeText,
type_class: ts.isMethodSignature(m) ? 'callable' : typeClass(typeText),
});
}
return {
surface: target.surface,
language: 'typescript',
location: `${location}:${lineOf(sf, decl)}`,
params,
extra,
};
}
function extractMethod(ts, sf, target, location) {
const matches = findAll(ts, sf, (n) =>
(ts.isMethodDeclaration(n) || ts.isFunctionDeclaration(n)) && n.name && n.name.getText(sf) === target.name
&& (!target.cls || enclosingClassName(ts, n) === target.cls));
const decl = matches[0];
if (!decl) {
const where = target.cls ? `${target.cls}.${target.name}` : target.name;
return { error: `${target.surface}: method ${where} not found in ${target.file}` };
}
const params = decl.parameters.map((p) => {
const d = literalOf(ts, p.initializer, sf);
const rest = !!p.dotDotDotToken;
const optional = rest || !!p.questionToken || !!p.initializer;
const typeText = p.type ? compact(p.type.getText(sf)) : 'any';
return {
name: p.name.getText(sf),
canonical: p.name.getText(sf),
kind: rest ? 'var_positional' : 'positional',
required: !optional,
default: d.default,
default_kind: d.default_kind,
type_text: typeText,
type_class: typeClass(typeText),
};
});
const extra = {};
// An options object (`options?: FooOptions`) declared as an interface in the
// same file is flattened: its members count as parameters of the method, the
// way Python spells them out as keyword arguments. Defaults are read from
// `options.x ?? v` / `options?.x ?? v` in the method body.
const flattened = [];
for (const p of decl.parameters) {
if (!p.type || !ts.isTypeReferenceNode(p.type) || !ts.isIdentifier(p.type.typeName)) continue;
const ifaceName = p.type.typeName.text;
const iface = findAll(ts, sf, (n) => ts.isInterfaceDeclaration(n) && n.name.text === ifaceName)[0];
if (!iface) continue;
const optName = p.name.getText(sf);
const optIsOptional = !!p.questionToken || !!p.initializer;
const defaults = collectDefaultsFrom(ts, decl, sf, p).defaults;
for (const m of iface.members) {
if (!ts.isPropertySignature(m) && !ts.isMethodSignature(m)) continue;
const name = m.name.getText(sf);
const typeText = ts.isMethodSignature(m)
? compact(m.getText(sf).replace(/^[^(]*/, ''))
: (m.type ? compact(m.type.getText(sf)) : 'any');
const d = defaults.get(name) || { default: null, default_kind: null };
flattened.push({
name,
canonical: name,
kind: 'property',
required: optIsOptional ? false : !m.questionToken,
default: d.default,
default_kind: d.default_kind,
type_text: typeText,
type_class: ts.isMethodSignature(m) ? 'callable' : typeClass(typeText),
via: optName,
});
}
(extra.options_interfaces = extra.options_interfaces || []).push(`${optName}: ${ifaceName}`);
}
params.push(...flattened);
const cls = enclosingClassName(ts, decl);
if (cls) extra.resolved_class = cls;
return {
surface: target.surface,
language: 'typescript',
location: `${location}:${lineOf(sf, decl)}`,
params,
extra,
};
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(opts.repoRoot);
const ts = loadTypescript(repoRoot);
const targets = readTargets(opts);
const tsRoot = path.join(repoRoot, opts.tsRoot);
const results = [];
const errors = [];
const cache = new Map();
for (const target of targets) {
for (const key of ['surface', 'file', 'kind', 'name']) {
if (!target[key]) errors.push(`target ${JSON.stringify(target)} is missing "${key}"`);
}
if (errors.length) continue;
const file = path.join(tsRoot, target.file);
if (!existsSync(file)) { errors.push(`${target.surface}: file not found: ${file}`); continue; }
if (!cache.has(file)) {
cache.set(file, ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true));
}
const sf = cache.get(file);
const location = path.relative(repoRoot, file).split(path.sep).join('/');
const out = target.kind === 'interface'
? extractInterface(ts, sf, target, location)
: target.kind === 'method'
? extractMethod(ts, sf, target, location)
: { error: `${target.surface}: unknown kind "${target.kind}" (expected interface|method)` };
if (out.error) errors.push(out.error); else results.push(out);
}
if (errors.length) fail(`${errors.length} surface(s) could not be extracted:\n ${errors.join('\n ')}`);
if (targets.length === 0) fail('no targets given: nothing was extracted');
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
}
main();
@@ -0,0 +1,509 @@
# Signature parity waivers.
#
# Key: "<surface key>.<python parameter>". Required: reason, owner.
# Optional: issue, expires (ISO date; an expired waiver FAILS the check).
# A waiver whose gap no longer exists is stale and FAILS the check: delete it.
# Regenerate missing entries with: python -m praisonai._dev.parity.signatures --baseline
waivers:
Agent.__init__.auth:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.autonomy:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.backend:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.caching:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.context:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.guardrails:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.handoffs:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.hooks:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.instructions:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.knowledge:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.learn:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.llm:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.memory:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.message_steering:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.model:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.name:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.planning:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.reasoning_effort:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.reflection:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.retry:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.role:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.rules:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.run_on:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.runtime:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.sandbox:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.self_improve:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.skills:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.templates:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.tool_config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.tool_search:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.tools_run_on:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.toolsets:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.__init__.web:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.attachments:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.force_retrieval:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.output_json:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.output_pydantic:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.reasoning_steps:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.seed:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.skip_retrieval:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.stream:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.task_description:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.task_id:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.task_name:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.temperature:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.tool_choice:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.chat.tools:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Agent.start.prompt:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.autonomy:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.caching:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.context:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.execution:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.guardrails:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.hooks:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.knowledge:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.learn:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.llm:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.manager_llm:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.memory:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.model:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.name:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.output:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.planning:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.process:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.reflection:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.run_on:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.tools_run_on:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.variables:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.__init__.web:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.start.content:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.start.output:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
AgentTeam.start.return_dict:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Handoff.__init__.config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Handoff.__init__.input_type:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Handoff.__init__.tool_description_override:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Handoff.__init__.tool_name_override:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.api_version:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.auth:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.claude_memory:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.events:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.failover_manager:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.frequency_penalty:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.logit_bias:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.logprobs:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.max_iter:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.n:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.presence_penalty:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.prompt_caching:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.response_format:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.seed:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.stop_phrases:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.timeout:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.top_logprobs:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.top_p:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.web_fetch:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
LLM.__init__.web_search:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Session.__init__.session_id:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Session.__init__.user_id:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.action:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.agent_config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.async_execution:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.autonomy:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.caching:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.callback:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.condition:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.create_directory:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.description:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.else_task:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.execution:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.expected_output:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.fail_on_callback_error:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.fail_on_memory_error:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.guardrail:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.guardrails:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.handler:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.hooks:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.id:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.images:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.input_file:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.is_start:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.knowledge:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.loop_over:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.loop_state:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.loop_var:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.max_retries:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.memory:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.name:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.next_tasks:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.on_error:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.on_task_complete:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output_config:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output_file:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output_json:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output_pydantic:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.output_variable:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.planning:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.quality_check:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.reflection:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.rerun:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.result:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.retain_full_context:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.retry_count:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.retry_delay:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.routing:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.should_run:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.skip_on_failure:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.status:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.task_type:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.then_task:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.tools:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.variables:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.web:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Task.__init__.when:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().approval:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().availability:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().description:
owner: praisonai-ts
reason: 'TS derives a default description from the tool name; Python leaves it None and infers from the docstring'
tool().dynamic_schema_overrides:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().func:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().name:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().requires_approval:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().restart_safe:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().retry_policy:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().to_model_output:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
tool().version:
owner: praisonai-ts
reason: 'baseline 2026-09-02: not yet ported to TypeScript'
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more