Skip to content

feat: print one-line JSON by default; add --pretty - #68

Merged
cryptotavares merged 1 commit into
cryptotavares/remove-ax-tree-pathfrom
cryptotavares/mmcli-p1-15-compact-json
Oct 29, 2026
Merged

cryptotavares merged 1 commit into
cryptotavares/remove-ax-tree-pathfrom
cryptotavares/mmcli-p1-15-compact-json

Conversation

@cryptotavares

@cryptotavares cryptotavares commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary: Print one-line JSON when stdout is a pipe or file, reducing bytes sent to agents. Keep terminal output indented and add --pretty and MM_PRETTY overrides.

Stack context: mm CLI v2, Phase 1, P1-15. Base: cryptotavares/remove-ax-tree-path; stacked on #44, which reviewers should read first. Structured errors (P1-16) and retry changes (P1-17) remain separate PRs.

Problem

The CLI indents every JSON result, including output that agents read through a pipe. Nested observations carry whitespace that JSON parsers do not need. Human terminal users still need readable output.

The existing historical proof below measures indentation at 37–47% of the sampled describe-screen and click output bytes. This PR changes serialization at the CLI boundary; it does not reduce daemon response content.

Solution

Resolve the output policy in main, then apply it to the final JSON serialization in sendRequest.

flowchart TD
    A[CLI arguments] --> B[Consume every --pretty token]
    B --> C[Choose indentation: flag, then environment, then terminal]
    C --> D[Run command and receive daemon response]
    D --> E[Assemble result, observations, and metadata]
    E --> F{Output is a string?}
    F -->|yes| G[Write raw text and newline]
    F -->|no| H[Choose compact or two-space JSON serialization]
    H --> I[Write JSON and newline]

    classDef new fill:#fff3cd,stroke:#d39e00
    class B,C,H new
Loading

Yellow boxes are new.

--pretty takes precedence over MM_PRETTY. The environment accepts 1 or true for indentation, and 0 or false for compact output. Values are trimmed and case-insensitive. An unset or unrecognized value falls back to process.stdout.isTTY === true.

extractPrettyFlag removes every exact --pretty token after the existing project and observation flag parsing. The flag controls CLI stdout only. Request bodies and daemon, tool, and platform contracts retain their existing behavior.

What changes

Condition or boundary Before After
JSON output to a pipe or file, without an override Two-space indentation One line per result, followed by a newline
JSON output to a terminal, without an override Two-space indentation Same indentation
--pretty, including with MM_PRETTY=0 No global formatting flag Two-space indentation
MM_PRETTY=1 or true, without the flag No environment override Two-space indentation
MM_PRETTY=0 or false, without the flag No environment override Compact JSON, including on a terminal
Final output value is a string Raw text and newline Same text behavior

Both JSON formats serialize the same assembled value. Existing result fields, observations, and meta keep their content and order. For example, the compact click test expects {"clicked":true,"observations":{"nodes":[{"ref":"e1"}]}} followed by a newline.

Decisions for reviewers

Decision Reason
Select the default from stdout, rather than the command or platform Agents normally use pipes. Terminal users retain readable output across browser, iOS, and Android commands.
Apply formatting after response assembly Keeps this change at the presentation boundary and preserves existing observation and timing metadata handling.
Use a flag, an environment override, then a terminal fallback Allows explicit indentation anywhere and explicit compact output on a terminal. Invalid environment values use the default.
Consume --pretty globally Matches the existing --observe placement convention. Literal --pretty text requires run-steps or the HTTP API.
Keep P1-15 limited to successful output formatting Error formatting and retry policy belong to P1-16 and P1-17.

Review guide

Read the files in this order:

# File What to check
1 src/cli/mm.ts: extractPrettyFlag, resolvePretty, and main Every flag occurrence is consumed; flag, environment, and terminal precedence stays explicit.
2 src/cli/mm.ts: setPrettyOutput, formatJsonOutput, and sendRequest Only the final JSON serialization changes. Plain strings and response assembly retain their paths.
3 src/cli/mm.test.ts Policy cases, repeated flags, exact compact/pretty output, environment isolation, and mocked terminal integration.
4 src/cli/mm.ts: printHelp; README.md; SKILL.md Document both overrides and tell agents to parse JSON rather than depend on indentation.
Testing

Fresh checks at remote head d43c8dd9486a4ebcec5d19933474198e17cdab8d:

  • CI=true yarn vitest run src/cli/mm.test.ts --coverage.enabled=false --maxWorkers=2 --minWorkers=1 passes: 1 file, 333 tests.
  • yarn eslint src/cli/mm.ts src/cli/mm.test.ts passes.
  • yarn tsc --noEmit -p tsconfig.build.json passes.
  • yarn prettier README.md SKILL.md --check and git diff --check pass.
  • yarn tsc --noEmit -p tsconfig.json fails with 11 diagnostics in seven test files outside this PR's delta. They cover mobile driver fixtures, server tests, mock-network narrowing, screenshot fixtures, and an in-page type predicate. Those files and TypeScript configurations are unchanged from the base.

The Proof and Unit tests sections below retain the original historical evidence. The fresh checks above do not repeat that live validation or the full suite.

Breaking changes

  • JSON results printed to a pipe or file are one line instead of 2-space indented. Callers that parse JSON (JSON.parse, jq) are not affected. To get the old output, pass --pretty or set MM_PRETTY=1.
  • --pretty is now consumed by the CLI anywhere in the command line. To type the literal text --pretty, use run-steps or the HTTP API (same as --observe).

Consumer impact

Consumer Change needed Done on branch
Extension test/e2e/playwright/llm-workflow/ None (no script reads mm output as text) n/a
Mobile tests/llm-workflow/ None (no script reads mm output as text) n/a

Proof

Tested with a yalc build of this branch in the Extension (e2e build, headless Chrome) and Mobile (dev client, iOS sim + Android emulator). "Pretty" is the compact output re-serialised with JSON.stringify(x, null, 2), which is what the CLI printed before. That keeps the comparison exact even if the screen changes between calls.

Platform Output Before (pretty) After (default) Saving
Extension describe-screen 9,816 B 6,176 B 37.1 %
Extension click (full observation) 9,444 B 5,675 B 39.9 %
iOS describe-screen 25,354 B 14,893 B 41.3 %
iOS click (full observation) 26,879 B 14,845 B 44.8 %
Android describe-screen 81,678 B 48,091 B 41.1 %
Android click (full observation) 74,935 B 39,579 B 47.2 %

With this PR the iOS wallet describe-screen meets the ≤ 15 kB target from #44.

Check (3 platforms) Result
Default output is one line and valid JSON pass
--pretty and MM_PRETTY=1 output is byte-identical to the old format pass
stdout is a TTY: indented by default pass
MM_PRETTY=0 on a TTY: one line pass
Unit tests
Test Files  52 passed (52)
     Tests  1827 passed (1827)
All files  97.53 % stmts | 94.91 % branches | 96.76 % funcs | 97.67 % lines

New tests: extractPrettyFlag, resolvePretty (precedence, env values), formatJsonOutput, sendRequest compact/pretty output, and main end to end (pipe, --pretty, --pretty with --observe, MM_PRETTY=1, TTY, TTY with MM_PRETTY=0). MM_PRETTY is stubbed in every test, so a developer's own value cannot change the results.

The CLI printed every result with 2-space indentation. Agents read stdout
through a pipe and pay for every byte; the indentation is a large share of
nested observations.

- Default output is single-line JSON when stdout is not a terminal.
- On a terminal the output stays indented, so humans see no change.
- `--pretty` (global, anywhere in argv) and `MM_PRETTY=1|true` force
  indented output; `MM_PRETTY=0|false` forces one line on a terminal.
- String results are unchanged.

BREAKING CHANGE: JSON results printed to a pipe or file are now one line
instead of 2-space indented. Pass `--pretty` or set `MM_PRETTY=1` to get the
old output. The JSON content is unchanged.
@cryptotavares
cryptotavares force-pushed the cryptotavares/mmcli-p1-15-compact-json branch from 2126223 to 2625aca Compare October 28, 2026 11:29
@cryptotavares cryptotavares changed the title feat!: print one-line JSON by default; add --pretty feat: print one-line JSON by default; add --pretty Oct 28, 2026

@seaona seaona left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cryptotavares
cryptotavares merged commit fd590de into main Oct 29, 2026
27 checks passed
@cryptotavares
cryptotavares deleted the cryptotavares/mmcli-p1-15-compact-json branch October 29, 2026 13:52
cryptotavares added a commit that referenced this pull request Oct 29, 2026
…refs (#64)

> **Summary:** Cancel timed-out requests cooperatively and fence ref-map
commits against aborted or older requests. Capture mobile trees and test
IDs together, and settle mobile observations after mutations.
>
> **Scope change (2026-10-27):** the ref-stability layer (renumbering
refs so unchanged elements keep their numbers) was removed in `6944868`.
Refs are positional again, as on `main`. See [Why no ref
stability](#why-no-ref-stability).

**Stack context:** mm CLI v2, Phase 1, **P1-08 + P1-09**, combined in
one PR. Base: [#62](#62)
(`cryptotavares/mmcli-p1-06-observe-level-no-state`); followed by
[#65](#65).

## Problem

A queue timeout rejects the caller, but the task can keep running and
overwrite another request's refs. An observation timeout also leaves
collection running.

Mobile observations read test IDs and the tree from separate snapshots.
A click can return the old screen or `Unlock, busy`.

## Solution

Keep cancellation and commit fencing at separate boundaries:

1. **Cooperative cancellation:** `RequestQueue` passes an `AbortSignal`
and aborts it before timeout rejection. Tools check supported
checkpoints. Observations use a linked controller and their own
deadline.
2. **Commit fencing:** the server owns a small `RefStore` (94 lines).
Each started task gets a generation. Every ref write
(`sessionManager.setRefMap` / `clearRefMap` from tools, and the
observation) checks the generation and request signal, even when a tool
ignores cancellation.

```mermaid
flowchart TD
    A[Queued task starts] --> B[Create signal and ref generation]
    B --> C[Run tool through guarded context]
    C --> D[Optional observation checks linked abort signal]
    C -->|ref write| F{Request current and not aborted?}
    D -->|ref write| F
    F -->|yes| G[Commit map]
    F -->|no| X[Drop write. Keep current refs.]
    T[Queue timeout aborts request] -.-> C
    T -.-> D
    T -.-> F

    classDef new fill:#fff3cd,stroke:#d39e00
    classDef stop fill:#f8d7da,stroke:#c82333
    class B,C,D,F,G,T new
    class X stop
```

Yellow boxes are new. Red boxes stop the ref write.

Tools write refs through `context.sessionManager`, which the daemon
proxies: `setRefMap` and `clearRefMap` go through the request handle.
Other members retain the real receiver, including `#private` fields.
`ISessionManager` stays unchanged.

Optional `captureScreen()` derives both mobile outputs from one
snapshot; drivers without it retain separate reads. After eligible
mutations, retry busy screens and allow one unchanged-screen retry,
compared against the last tree the agent received (`lastScreen`: the
delivered observation, else the `describe_screen` / full
`accessibility_snapshot` tree). Launch, cleanup, and non-batch tools
that set refs skip settling.

## What changes

| Condition or boundary | Before | After |
|---|---|---|
| Queued task times out | Remaining work can continue and replace refs |
Abort checkpoints stop cooperative work; the commit fence drops later
ref writes |
| Observation reaches its own timeout | Collection continues after the
response | Abort collection; the final checkpoint prevents its late ref
write |
| Observation not delivered (`observe none`) | Still became the diff
baseline | Not recorded; the next diff is against what the agent last
saw |
| `list_testids` | Reads the tree and replaces refs despite returning no
refs | Collect only test IDs; leave refs unchanged |
| Mobile discovery and observations | Separate snapshots can disagree or
show an unsettled screen | Share a snapshot when supported; bound
post-mutation settle retries |

Queue timeout errors use HTTP 500 with `TOOL_EXECUTION_FAILED` and `Tool
execution timed out after ${effectiveTimeout}ms`. `MM_REQUEST_ABORTED`
is the internal checkpoint response. Cancellation does not undo an
action already dispatched to a driver. Refresh with `describe-screen`
before retrying.

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Keep abort checkpoints and the commit fence | A driver operation can
finish after cancellation. Ref ownership must remain safe independently.
Synchronous abort-handler settlement can win the queue's timeout race. |
| No ref stability; refs stay positional | See [Why no ref
stability](#why-no-ref-stability). |
| The observation's refMap always wins, also after a tool set refs | The
observation is the last tree in the response, so every ref in it
resolves. |
| `lastScreen` updates only after a delivered response | A timed-out
request's tree is never the settle baseline. |

## Review guide

Read the files in this order:

| # | File | What to check |
|---|---|---|
| 1 | `src/server/request-queue.ts`, `src/types/http.ts` |
Abort-before-reject ordering, bounded settle wait, timeout clamp, and
optional context additions. |
| 2 | `src/server/ref-store.ts` | Generation and abort checks;
`toolSetRefs`. |
| 3 | `src/server/create-server.ts` | Proxy receiver binding
(`setRefMap`, `clearRefMap`), observation cancellation checkpoints,
`screenAfterResponse` / `lastScreen`, batch behavior, separate diff
baseline. |
| 4 | `src/tools/utils.ts`, `src/tools/interaction.ts`,
`src/tools/batch.ts`, `src/tools/discovery-tools.ts` | Checkpoints and
`list_testids` tree-read removal. |
| 5 | `src/platform/types.ts`, `src/platform/mobile-platform-driver.ts`,
`src/server/screen-settle.ts` | Optional capture boundary, shared
snapshot, busy detection, and bounded retries. |
| 6 | Tests beside these modules; `README.md`, `SKILL.md` | Timeout
isolation, matching exceptions, snapshot consistency, and caller
recovery guidance. |

<details>
<summary>Testing</summary>

At `6944868`: `yarn test` 52 files / 1528 tests pass; coverage 97.03 /
93.95 / 95.21 / 97.19; `yarn lint` passes. Every branch above it in the
stack (#65, #66, #67, #44, #68) was restacked and passes `yarn test` +
`yarn lint` at its own tip.

</details>

## Breaking changes

None.
- `ToolContext.signal`, `IPlatformDriver.captureScreen` and
`ScreenCapture` are optional additions.
- `RequestQueue.enqueue` now passes the signal to its callback. Existing
callbacks that take no argument keep working.

## Consumer impact

| Consumer | Change needed | Done on branch |
|---|---|---|
| Extension `test/e2e/playwright/llm-workflow/` | None. It does not use
`RequestQueue`, and its `setRefMap`/`getRefMap` implementation is
unchanged. | n/a |
| Mobile `tests/llm-workflow/` | None (same). | n/a |

## Why no ref stability

The first version of this PR also renumbered refs so an element kept its
number across observations (`stabilizeRefs`, a session high-water mark,
baseline checks, merging tool and observation refs: about 500 lines of
code and 900 of tests). It was removed because the problem it solves is
rare:

- Refs are deterministic per tree: the same screen gives the same refs,
and an unchanged screen diffs as empty (device proof below).
- Since P1-05/P1-06, read commands collect no observation, so they no
longer renumber anything.
- The only case it fixed is an element inserted above the one the agent
holds. The response then carries the new refs (the diff lists the
shifted nodes, or the full tree when every node shifted), and SKILL.md
tells agents to use the refs from the latest response.
- No baseline defect (B1–B15) was a wrong-element click caused by
renumbering.

If real runs show agents clicking stale refs, it can come back as its
own ticket.

## Proof

**After the shrink (`6944868`, run on the restacked stack tip):**

| Check | Extension | iOS | Android |
|---|---|---|---|
| Timed-out click, then `describe-screen` (queueMs 0), then click by ref
| pass | pass | pass |
| `describe-screen` twice: identical refs | 17/17 | 67/67 | 242/242 |
| Unchanged screen, 3 taps: diff | 4,243 B, +0 −0 | 4,860 B, +0 −0 |
5,306 B, +0 −0 |
| Navigate away and back, click by the latest ref | pass | pass | pass |

Android navigation observations can still show the previous screen (the
new screen mounts about 2 s later). That is the existing Android settle
limit noted in #44, not changed here.

**Earlier revision (with ref stability), kept for history:**

Tested with yalc builds in the Extension (e2e build, headless) and
Mobile (dev client on iOS sim and Android emulator). The first full run
used an earlier revision (e804b95). The final commit got a targeted
mobile recheck plus a smoke run on all three platforms. Details are in
the proof file in the mm-cli-v2 workspace.

| Check | Before (#62) | After |
|---|---|---|
| `click --testid does-not-exist --timeout 500`, then `describe-screen`,
then click by ref | – | works on ext, iOS and Android (queueMs 0,
intended element clicked) |
| Android unlock-click observation | login screen ("Unlock, busy") in
3/3 | no login or busy in 4/4 (3 wallet screen, 1 tab bar only) |
| Unchanged-screen diff, iOS (`wait-for wallet-screen`) | 7495 B | 7518
B, diff +0 −0 |
| Unchanged-screen diff, Android (`click tab-bar-item-Wallet`, p50 of 3)
| 7672 B, observationMs 1924 | 40–110 KB, observationMs **965–1001**
(one snapshot instead of two) |
| Ext bench p50 (click / get-state / list-testids totalMs) | 580 / 5 /
565 | 582 / 6 / **60** (`list_testids` no longer reads the tree) |

The Android unchanged-screen diff gap in that revision is closed by #67
and #44 (see the table above).
cryptotavares added a commit that referenced this pull request Oct 29, 2026
> **Summary:** Reduce iOS and Android accessibility output by omitting
ancestor `path` and pruning layout-only elements. Keep named nodes,
controls, progress indicators, and unlabelled coordinate targets.

**Stack context:** mm CLI v2, Phase 1, **P1-14**. Base:
`cryptotavares/mmcli-p1-13-mobile-snapshot`; review
[#67](#67) first. Next:
[#68](#68).

## Problem

Mobile `path` repeats native ancestor class names, such as
`android.widget.FrameLayout` and `RCTViewComponentView`. These names add
no ref or testId targeting information. Unnamed layout wrappers further
inflate `describe-screen` and observation payloads.

Browser ancestry has a different purpose: it holds dialog and heading
context used by row collapse. Shared comparison code must accept mobile
nodes without losing that browser context.

On iOS, idb also varies frame key order. Equivalent frames can therefore
produce different serialized screen signatures.

## Solution

Apply pruning at the `MobilePlatformDriver` normalization boundary,
after the app-hierarchy filtering introduced by P1-13.

```mermaid
flowchart TD
    A[App-filtered mobile snapshot] --> B[Prune layout-only elements; hoist kept descendants]
    B --> C[Keep unlabelled RN leaves at least 24 x 24]
    C --> D[Flatten in pre-order; number kept nodes]
    D --> E[Omit path; write bounds in fixed key order]
    E --> F[Build refMap for testId, label, or value]
    F --> G[Mark duplicate stable identifiers as ambiguous]
    A --> H[Read test IDs and locators from app-filtered hierarchy]

    classDef new fill:#fff3cd,stroke:#d39e00
    class B,C,D,E new
```

Yellow boxes are new.

`pruneLayoutOnly` keeps elements with a label, value, identifier,
actionable type, or progress-indicator type. It matches exact iOS AX
types and Android class-name suffixes. Removed wrappers give way to
their kept descendants, in order. The function preserves input objects
and returns unchanged subtrees by reference.

A plain React Native view also stays when it has no kept children and
measures at least 24×24. This retains unlabelled icon buttons, including
the iOS wallet action buttons. These nodes expose `bounds` for
`tap-coordinates`.

Both `getAccessibilityTree` and `captureScreen` use this normalization.
Test IDs and generated locators still use the app-filtered hierarchy
before layout pruning. Browser nodes retain their `path`. Shared diffing
and row collapse treat two absent paths as equal, but distinguish
absence from an empty array.

The filter and screen-settle logic share `MOBILE_BUSY_TYPE`, so pruning
retains the progress indicators that settling detects. Normalization
writes bounds in `x, y, width, height` order to stabilize screen
signatures.

## What changes

| Condition or boundary | Before | After |
|---|---|---|
| iOS and Android accessibility trees | Include layout wrappers and
every ancestor role in `path` | Omit `path`; flatten kept nodes in
pre-order |
| Named or identified nodes, controls, and progress indicators | Listed
among layout wrappers | Stay listed; refs number only kept nodes |
| Unlabelled plain RN views | All sizes appear | Keep leaves after
pruning when both dimensions are at least 24; use their bounds for
coordinate taps |
| Ref targeting | Only testId, label, or value creates a `refMap` entry
| Same rule; a displayed ref alone does not make an unnamed node
targetable |
| Optional ancestry | Shared comparisons require path arrays | Accept
absent mobile paths; preserve browser ancestry and option-summary paths
when present |
| iOS bounds serialization | Uses backend frame key order | Always emits
`x, y, width, height` |

The original Android-only change (`3bbda5c`) now covers both mobile
platforms under **P1-14**. The protected proof below records payload
reductions and the remaining size-target gaps.

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Prune in the mobile driver | Discovery and observations share one
rule. Test ID collection and locator generation keep their existing
input. |
| Use platform-specific control types and a minimum-size RN leaf rule |
Snapshots have no `clickable` flag. The leaf rule retains unlabelled
wallet action buttons while removing wrappers. |
| Keep listed nodes separate from targetable refs | Progress indicators,
unnamed controls, and unlabelled views still provide useful screen or
coordinate information. Existing stable-identifier targeting stays
intact. |
| Make the shared `path` field optional | Mobile drops native ancestry.
Browser dialog and heading context remains available to row collapse. |

## Review guide

Read the files in this order:

| # | File | What to check |
|---|---|---|
| 1 | `src/tools/types/discovery.ts`, `src/tools/utils/constants.ts` |
Optional public `path`, shared progress-indicator matching, and the
minimum tap-target size. |
| 2 | `src/platform/mobile-hierarchy.ts` | Control classification,
descendant order, the RN leaf exception, and unchanged-subtree identity.
|
| 3 | `src/platform/mobile-platform-driver.ts` | Both normalization
entry points, sequential refs, targeting-map eligibility, ambiguity, and
ordered bounds. |
| 4 | `src/server/observation-compaction.ts`,
`src/server/screen-settle.ts`, `src/tools/utils/discovery.ts` |
Optional-path comparisons, option summaries, shared busy types, and
retained browser ancestry. |
| 5 | `src/platform/mobile-hierarchy.test.ts`,
`src/platform/mobile-platform-driver.test.ts`,
`src/platform/__fixtures__/ios-wallet-home-preorder.json` | Android
login and iOS wallet fixtures, controls, unlabelled action bounds, ref
eligibility, and input preservation. |
| 6 | Server and discovery tests; `README.md`, `SKILL.md` | Same refs
across wrapper changes (`mobile-platform-driver.test.ts`), absent-path
diffs, browser paths, and mobile targeting guidance. |

<details>
<summary>Testing</summary>

Fresh checks at remote head `f38073e4ad5732ade9c22d5f6e486753a44b6416`:

```sh
CI=true yarn vitest run src/platform/mobile-hierarchy.test.ts src/platform/mobile-platform-driver.test.ts src/server/observation-compaction.test.ts src/server/screen-settle.test.ts src/server/create-server.test.ts src/tools/utils/discovery.test.ts --coverage.enabled=false --maxWorkers=2
```

- Passes: 7 files, 506 tests. The server suite emits the expected
readonly-log `EACCES` diagnostic.
- `yarn lint` passes.
- `yarn tsc --noEmit --project tsconfig.build.json` passes for
production source.
- `yarn tsc --noEmit --project tsconfig.json` fails with 11 existing
test-file diagnostics at this unchanged head. They include mock typing,
top-level `await`, and a missing `ToolContext` import.
- The `Proof` and `Unit tests` sections below retain historical
evidence. The fresh checks above cover focused tests, lint, and
production types.

</details>

## Breaking changes

- `A11yNodeTrimmed.path` is optional and absent on mobile nodes.
- Mobile trees no longer include unnamed layout nodes, so mobile ref
numbers change. Refs to unnamed nodes never resolved anyway.

## Consumer impact

| Consumer | Change needed | Done on branch |
|---|---|---|
| Extension `test/e2e/playwright/llm-workflow/` | None (nothing reads
`node.path`) | n/a |
| Mobile `tests/llm-workflow/` | None (nothing reads `node.path`) | n/a
|

## Proof

Tested with yalc builds in Mobile (dev client) on the iOS simulator (idb
nested format, MetaMask/device-mcp#38) and the Android emulator
(MetaMask/device-mcp#39). Before = the #67 build. Byte counts are for
the pretty JSON the CLI prints, with compact JSON in brackets.

| Check | Before (#67) | After (f38073e) |
|---|---|---|
| iOS wallet home `describe-screen` | 286,526 B, 177 nodes, 6,613 `path`
entries | **25,353 B** (14,933 compact), 67 nodes, 0 |
| iOS wallet home buttons / tabs / inputs | 21 | 21 (0 missing); the 4
unlabelled action buttons are listed with bounds |
| Android wallet home `describe-screen` | 987,001 B, 625 nodes, 21,658
`path` entries | **84,271 B** (49,772 compact), 0 |
| Android wallet home actionables (incl. every testId) | 144 | 144 (0
missing) |
| Android click observation: unchanged screen / screen change / full |
7,846 / 14,077 / 1,031,678 B | 7,849 / 8,047 / **74,963 B** (39,690
compact) |
| iOS unchanged-screen click diff, 5 runs | varied by up to 17 nodes |
empty 5/5 (bounds key order fixed) |
| Click by ref, 3 targets per platform | – | iOS 3/3; Android 3/3 |
| Tap a listed action button (Receive) via `tap-coordinates` at its
bounds | – | opens the receive screen |

**Acceptance criteria not fully met:**
- iOS `describe-screen` is ≤ 15 kB only as compact JSON. The CLI
pretty-prints with a 2-space indent, which is ~41 % of the bytes.
- The Android **full** observation (first click on a screen) is 75 kB,
against a 20 kB target. Diffs on later clicks are ~8 kB.

The remaining size is named content (token rows, banners, prices) and
`bounds` (~30 % of mobile bytes). Possible next steps: compact CLI
output, dropping or rounding `bounds`, and a cap on long lists.

**Known issues not caused by this PR:**
- device-mcp's `findElement` accepts a substring testId match and takes
the first one, so `click --testid account-picker` on Android taps
`account-picker-avatar`.
- The Android unlock click observation still shows "Unlock, busy" under
host load.

<details>
<summary>Unit tests</summary>

```
Test Files  52 passed (52)
     Tests  1803 passed (1803)
Type Errors no errors
All files | 97.51 | 94.88 | 96.73 | 97.66
yarn lint → exit 0
```

New tests:

- `pruneLayoutOnly`, `isLayoutOnly`, `isActionableElement`: iOS exact
types, `XCUIElementType` prefix not actionable, Android suffixes incl.
`CheckedTextView` / `RatingBar`, progress indicators, unlabelled leaf ≥
24×24.
- Real iOS wallet-home fixture: 183 → 67 nodes; all buttons and tabs
kept; the 4 action buttons listed with bounds.
- Real Android login fixture.
- No `path` on mobile (iOS and Android); browser `path` kept; diff and
collapse with absent `path`.
- Bounds key order; shared busy regex.

</details>
cryptotavares added a commit that referenced this pull request Oct 29, 2026
> **Summary:** Upgrade `@metamask/device-mcp` to `^0.6.0`. 0.6.0 gives
correct iOS trees (nested idb format) and fast, correct Android
snapshots on busy screens (signed helper first). #44 and #67 were
measured with these changes.

**Stack context:** mm CLI v2, Phase 1, **P1-30**. Base:
[#68](#68), the top of
the stack; review that first.

## Problem

Core pins `@metamask/device-mcp` `^0.5.0` (#58). On 0.x a caret range
does not pick up 0.6.0. Without 0.6.0:

- **iOS:** device-mcp cannot read the nested `idb ui describe-all`
format, so the element tree loses its hierarchy
([device-mcp#38](MetaMask/device-mcp#38)).
- **Android:** snapshots start with `uiautomator dump`. On busy screens
this waits about 11 s for an idle state, then fails with "could not get
idle state"
([device-mcp#39](MetaMask/device-mcp#39)).

#44 (flat mobile tree) and #67 (mobile snapshot filtering) were measured
against yalc builds of those two device-mcp PRs.

## Solution

Adopt the device-mcp 0.6.0 release through the dependency range and the
lockfile. Add one README sentence about the new Android helper install.

```mermaid
flowchart TD
    A[Mobile describe / observation] --> B[Core MobilePlatformDriver]
    B --> C{device-mcp 0.6.0 snapshot}
    C -->|iOS| D[idb describe-all, nested format read]
    C -->|Android default helper-first| E[Signed helper APK, no idle wait]
    E -->|non-trust failure| F[1 uiautomator dump attempt]
    D --> G[Core flat tree, refs, testIds]
    E --> G
    F --> G

    classDef new fill:#fff3cd,stroke:#d39e00
    class D,E,F new
```

Yellow boxes are new in 0.6.0.

## What changes

| Boundary | Before | After |
|---|---|---|
| `package.json` dependency range | `@metamask/device-mcp ^0.5.0` |
`^0.6.0` |
| `yarn.lock` resolution | npm 0.5.0 | npm 0.6.0 (dependencies
unchanged) |
| Android snapshot on the consumer's ADB backend | `uiautomator dump`
first | Signed test-only helper first, installed on first use.
`DEVICE_MCP_ADB_SNAPSHOT=dump` restores the old path |
| `README.md` | No mention of the helper | One sentence: helper install,
first snapshot slower, how to opt out |

## Decisions for reviewers

| Decision | Reason |
|---|---|
| New PR on top of the stack instead of amending #58 | Amending #58
would rebase the whole stack again. 0.6.0 was not released when the
stack was built. |
| `chore:`, not breaking | Every symbol core imports from device-mcp is
unchanged. `SnapshotResult` only gains optional fields (`source`,
`captureMs`), and core reads only `hierarchy`. The new Android default
is a dependency runtime change on the consumer's backend, not a change
to core's API. |
| Core does not set `snapshotMode` | The consumer creates the
`DeviceBackend` (Mobile uses `AdbBackend` directly). Core only documents
the default and the opt-out. |

## Review guide

| # | File | What to check |
|---|---|---|
| 1 | `package.json` | The production dependency range is `^0.6.0`. |
| 2 | `yarn.lock` | Only the device-mcp entry and the workspace range
change. |
| 3 | `README.md` | The helper sentence in the mobile snapshot
paragraph. |

<details>
<summary>Testing</summary>

**Unit and lint** (at afaf75c, device-mcp 0.6.0 from npm, no yalc):
`yarn build` OK, `yarn test` 51 files / 1793 tests pass, `yarn lint`
passes (including the dedupe and constraints checks).

**Device proof:** Mobile with this branch's core (yalc) and **device-mcp
0.6.0 from npm** (no yalc device-mcp). Real-funds wallets; only read
actions, tab bar Home/Explore, account list open/back and settings list
(no row opened).

| Check | Extension | iOS | Android |
|---|---|---|---|
| Smoke: launch, unlock, describe, list-testids, get-state, cdp,
screenshot | PASS | PASS | PASS |
| A timed-out click does not block the next call; click by ref after it
| n/a | PASS | PASS |
| Two describes give identical refs | n/a | 67 / 67 | 242 / 242 |
| Unchanged screen diff | n/a | +0 −0 | +0 −0 |
| Ref from the latest describe clicks the intended element | n/a | PASS
| PASS |

Node counts are identical to the yalc device-mcp measurements used for
#44 (`measure-tree.sh`):

| Screen | iOS nodes | Android nodes |
|---|---|---|
| Wallet home | 67 → 67 | 242 → 242 |
| Account list | 35 → 35 | 33 → 33 |
| Settings | 26 → 26 | 65 → 65 |

**Android helper path:** the emulator had helper 0.5.0. On the first
snapshot, device-mcp 0.6.0 installed its bundled, signed 0.6.0 helper,
and the signer check passed. During the smoke run, logcat shows 106
helper runs and 0 `uiautomator` runs.

**Known and unchanged:** on Android, 3 of 4 navigation observations
still show the previous screen (same as before this bump). That is
tracked separately.

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants