Skip to content

feat: cancel timed-out tasks; late ref writes cannot overwrite newer refs - #64

Merged
cryptotavares merged 5 commits into
cryptotavares/mmcli-p1-06-observe-level-no-statefrom
cryptotavares/mmcli-p1-08-abort-refs
Oct 29, 2026
Merged

cryptotavares merged 5 commits into
cryptotavares/mmcli-p1-06-observe-level-no-statefrom
cryptotavares/mmcli-p1-08-abort-refs

Conversation

@cryptotavares

@cryptotavares cryptotavares commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Stack context: mm CLI v2, Phase 1, P1-08 + P1-09, combined in one PR. Base: #62 (cryptotavares/mmcli-p1-06-observe-level-no-state); followed by #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.
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
Loading

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

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.

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
cryptotavares added this pull request to stack #63 October 24, 2026 01:07
Comment thread src/server/request-queue.ts Fixed
@cryptotavares
cryptotavares force-pushed the cryptotavares/mmcli-p1-08-abort-refs branch from de9777c to 5131d82 Compare October 24, 2026 01:20
@cryptotavares

Copy link
Copy Markdown
Collaborator Author

Rebased onto #62 (1bbfc70). Fixed CodeQL js/resource-exhaustion in 5131d82: queued task timeouts (from request bodies) are clamped to MAX_QUEUE_TIMEOUT_MS = 600 s. Core: 49 files / 1561 tests, lint 0.

@cryptotavares
cryptotavares force-pushed the cryptotavares/mmcli-p1-08-abort-refs branch from 5131d82 to 69cf663 Compare October 24, 2026 01:32
@cryptotavares
cryptotavares force-pushed the cryptotavares/mmcli-p1-08-abort-refs branch from 69cf663 to 7d0c565 Compare October 24, 2026 01:35
@cryptotavares

Copy link
Copy Markdown
Collaborator Author

CodeQL js/resource-exhaustion on request-queue.ts is the same finding that is already dismissed on main (alert #32, "used in tests"); it re-appears because the lines moved. The timeout is now clamped to MAX_QUEUE_TIMEOUT_MS (600 s) and falls back to the default for non-positive values (7d0c565), and the schemas already cap every timeoutMs. Needs a maintainer to dismiss the re-opened alert.

@cryptotavares cryptotavares changed the title feat: cancel timed-out tasks; stable refs that late writes cannot overwrite feat: cancel timed-out tasks; late ref writes cannot overwrite newer refs Oct 27, 2026
@cryptotavares
cryptotavares marked this pull request as ready for review October 27, 2026 16:22
@cryptotavares
cryptotavares requested a review from a team as a code owner October 27, 2026 16:22
…rwrite

A request that timed out kept running: it could still click, snapshot and
then overwrite the refMap the next request relied on. Refs were renumbered
by every observation and discovery call, so refs an agent held could point
at other elements. On mobile, a click observation could show the old screen.

- RequestQueue passes an AbortSignal to each task and aborts it on timeout.
  ToolContext gains optional `signal` and `setRefMap`. Interaction tools,
  run_steps, discovery tools and the observation check the signal between
  steps (MM_REQUEST_ABORTED, internal).
- New RefStore (server-owned, wraps sessionManager.setRefMap): each request
  takes a generation; writes from an older or aborted request are dropped.
  Tools that call context.sessionManager.setRefMap go through it too (proxy).
  ISessionManager is unchanged.
- Stable refs: discovery tools and observations reuse the previous ref for
  the same element (browser key: role + name + testId; mobile: testId, else
  role + name; elements sharing a key are matched in screen order).
  ToolContext.setRefMap returns the stabilized { refMap, nodes }, which
  describe_screen / accessibility_snapshot return. New elements get refs
  above a session high-water mark, so a removed ref is never reused until
  launch/cleanup. When a tool set refs, the observation merges into them.
- list_testids no longer reads the a11y tree or sets refs (it returns none).
- Mobile observation and describe_screen: one device snapshot for both the
  tree and test IDs (optional IPlatformDriver.captureScreen; ScreenCapture
  type exported). After a mutating or batch call, re-snapshot every 300 ms
  while the screen is busy (", busy" label, progress/activity indicator), at
  most 3 times / 2 s; an unchanged screen gets one retry.
- The observation timeout now aborts the observation instead of leaving it
  running.
CodeQL (js/resource-exhaustion) flagged the queue timer because its
duration comes from the request body. Clamp it to MAX_QUEUE_TIMEOUT_MS.
Ref renumbering (stabilizeRefs, the session high-water mark, baseline
checks, merging tool and observation refs) solved a problem that rarely
happens: refs are already deterministic per tree, so an unchanged screen
keeps its refs and diffs as unchanged, and read commands no longer
collect observations (P1-05/P1-06). When elements are inserted above an
element, the diff (or the full tree) carries the new refs.

Kept:
- AbortSignal per queued task, and RefStore as a generation guard: a
  write from a timed-out or older request never reaches the session
  manager (`context.sessionManager.setRefMap` and `clearRefMap` go
  through it too).
- lastObservation only when delivered.
- Mobile settle after a mutating tool, now compared against the last
  tree the agent received (`lastScreen`: the delivered observation, else
  the describe_screen / full accessibility_snapshot tree). No settle when
  a non-batch tool set refs (it just read the screen).

Changed: the observation's refMap always wins, also after a tool set
refs (describe_screen --observe), so every ref in the last tree of a
response resolves.

Removed: ref-stability.ts, ToolContext.setRefMap, setToolRefMap, the
RefStore baseline / high-water mark / key modes.
@cryptotavares
cryptotavares force-pushed the cryptotavares/mmcli-p1-08-abort-refs branch from 6944868 to 674ef64 Compare October 28, 2026 11:29
@itsyoboieltr
itsyoboieltr requested a balanced review from Copilot October 28, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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

2 small comments 🙏

Comment thread src/tools/batch.ts
const aborted = abortedResponse<RunStepsResult>(context);
if (aborted) {
return aborted;
}

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.

batchTimeoutMs allows up to 300 s, but the queue budget only comes from timeoutMs, which run_steps doesn't have. So batches get the 30 s default, and the CLI also uses 30 s. Before, a batch that hit it kept running in the background. With this check, it's now cut off at the next step. Should the queue and CLI budgets follow batchTimeoutMs, or should its max be lowered to about 30 s?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks. We went with the lower max: batchTimeoutMs is now 1000-30000 (#71, fix!: cap run_steps batchTimeoutMs at 30 s), like the cdp timeoutMs. The schema text and docs say the deadline is checked between steps, so to get partial results it should be below the 30 s request limit (e.g. 20000). Longer flows can be split into several batches.

Comment thread src/tools/interaction.ts
if (aborted) {
return { error: aborted };
}

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.

This check runs at the start of the click, before the queue timer could have fired, so it possibly almost never triggers. Inside run_steps, batch.ts already checks between steps. The slow part, waiting for the target, never sees the signal, so a timed-out click can still happen later.

Example, not sure if could happen in reality, but may be in slow devices?

0 s   mm click --testid confirm-button   → checked here: not aborted, starts waiting for the target
30 s  queue timeout → agent gets "timed out"; signal aborted, but nothing checks it
35 s  queue settle wait ends → agent's next command (describe-screen) starts
38 s  target found → the click happens anyway, while the agent thinks it didn't

Suggestion: could driver.click and driver.type take an optional signal and call signal?.throwIfAborted() after the target wait, right before the tap or click? Both drivers already split wait and action, so it's a one-line check per driver, and it stops a timed-out click from landing later.

// src/platform/types.ts
click(
  targetType: TargetType,
  targetValue: string,
  refMap: Map<string, string>,
  timeoutMs: number,
  within?: WithinScope,
  signal?: AbortSignal,
): Promise<ClickActionResult>;

and then each driver checks it right before the action:

// playwright-driver.ts, after waitForTarget(...)
signal?.throwIfAborted();
await locator.click({ timeout: remaining });

// mobile-platform-driver.ts, after waitForElement(...)
await this.#backend.waitForElement(query, timeoutMs);
signal?.throwIfAborted();
await withTimeout(this.#backend.tapElement(query), remaining, 'tapElement');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, fixed in #71 (fix: do not click or type after the request timed out) as you suggested: IPlatformDriver.click/type take an optional signal, and both drivers call signal?.throwIfAborted() after the target wait, right before the action (mobile type also between the focus tap and typeText). The tools pass context.signal and return MM_REQUEST_ABORTED. Verified on the extension: a run_steps click whose target appears 1 s after the 30 s queue timeout clicked before, and does not click now. Limit (documented): an action that already started is not stopped. device-mcp's waitForElement/tapElement/typeText take no signal, and Playwright's actionability checks are part of locator.click. Cancelling inside device-mcp needs a device-mcp change, kept as a follow-up.

@cryptotavares
cryptotavares merged commit 1cdd405 into main Oct 29, 2026
27 of 28 checks passed
@cryptotavares
cryptotavares deleted the cryptotavares/mmcli-p1-08-abort-refs branch October 29, 2026 13:52
cryptotavares added a commit that referenced this pull request Oct 29, 2026
> **Summary:** Let each tool call choose `observe: 'none' | 'diff' |
'full'`. Remove app state from observations and stop the mobile driver
from inventing wallet-state values.

**Stack context:** mm CLI v2, Phase 1, **P1-06 + P1-07**, combined at
the shared observation boundary. Base:
`cryptotavares/mmcli-p1-05-observe-mutating-only`; review
[#61](#61) first.
Followed by [#64](#64).

## Problem

Tool categories fix the observation policy. Callers cannot skip a
snapshot while chaining clicks or request one after a read. `run_steps`
uses a separate `includeObservations` policy.

Observations also combine screen data with app state. Mobile returns
guessed wallet values, including `isUnlocked: true` and `currentScreen:
"unknown"`. Browser observation collection reads extension state and can
spend another 500 ms rechecking an unknown screen.

These concerns belong at different boundaries: the daemon controls
observations, while `get_state` reports platform state.

## Solution

Add one shared observation field to registered tool schemas. Consume it
in the daemon, outside the tool-handler contract.

```mermaid
flowchart TD
    A[CLI --observe or HTTP observe] --> B[Validate the shared observe field]
    B --> C[Strip observe before handler dispatch]
    C --> D[Execute the tool or batch]
    D --> E[Resolve explicit level or default]
    E -->|none or no active session| F[Return the tool response]
    E -->|diff or full with active session| G[Collect test IDs and a11y only]
    G --> H[Compact options; full skips diff]
    H --> F

    classDef new fill:#fff3cd,stroke:#d39e00
    class A,B,C,E,G,H new
```

Yellow boxes are new.

`withObserve()` preserves existing schema refinements and defaults
through intersections. Both HTTP and batch dispatch call `omitObserve()`
before the handler. Consumer launch implementations therefore receive
their existing input shape.

For `run_steps`, the top-level level wins. Otherwise, the last executed
step's `args.observe` wins, then `diff`. Timeout-skipped steps do not
count. Earlier per-step levels do not trigger separate observations.

Observation collection reads only test IDs and accessibility nodes. It
no longer calls `getExtensionState()` or `getAppState()`. Browser
collection waits for page settling for mutating, batch, and CDP calls.
Read-only and discovery calls skip that wait.

## What changes

| Condition or boundary | Before | After |
|---|---|---|
| Default tool call | Category controls collection; batch has a separate
policy | `diff` for mutating tools and batches; `none` for read-only,
discovery, and CDP tools |
| Explicit `observe: 'none'` | No shared override | Skips collection and
returns no observations; `observationMs` is zero |
| Explicit `observe: 'diff'` or `'full'` with an active session | No
shared override | `diff` uses the previous snapshot; `full` returns the
whole option-collapsed tree without diff metadata |
| CLI `--observe <level>` | Unsupported | Accepted anywhere; injected
into POST `/tool/*`, `/launch`, and `/cleanup` object bodies; an
existing body field wins |
| `run_steps.includeObservations` | Accepts `all`, `none`, or `failures`
| Fails validation with `includeObservations was removed; use observe` |
| Observation payload | App state, test IDs, and a11y | Test IDs and
a11y only; use `get_state` for app state |
| Mobile `getAppState()` | Adds guessed wallet fields | Returns `{
isLoaded, extensionId, source: 'stub' }`; unread fields are omitted |

For ordinary calls, a `full` observation becomes the next diff baseline;
`none` leaves it unchanged. Existing reset tools still clear it:
`describe_screen`, `accessibility_snapshot`, `launch`, and `cleanup`.

An invalid CLI level exits with code 1 and prints:

```text
Error: --observe requires one of: none, diff, full
```

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Combine **P1-06** and **P1-07** | Observation selection and state
removal change the same collection path. Keeping one boundary avoids
separate platform policies. |
| Consume `observe` outside handlers | The daemon owns collection.
Shared validation accepts the field without passing it into consumer
implementations. |
| Collect once after a batch | The final screen supplies the batch
observation. Top-level configuration takes precedence over the last
executed step. |
| Keep `full` compacted | Callers skip diffing but retain option
collapsing. The existing discovery tools remain available for unfiltered
trees. |
| Mark mobile state as partial | Optional `ExtensionState` fields
distinguish unread data from real false, empty, or null values. |
| Keep the registered-function dispatch guard | The HTTP executor checks
registry membership and callable type before dispatching a
request-derived tool name. |

## Review guide

Read the files in this order:

| # | File | What to check |
|---|---|---|
| 1 | `src/validation/schemas.ts`, `src/tools/types/tool-inputs.ts` |
Shared levels, schema intersections, removed batch policy, and
`omitObserve`. |
| 2 | `src/server/create-server.ts`, `src/tools/batch.ts`,
`src/tools/registry.ts` | Override precedence, timeout-skipped steps,
handler isolation, category defaults, and registered-function dispatch.
|
| 3 | `src/tools/types/observation.ts`,
`src/server/observation-compaction.ts` | State-free snapshots, option
compaction, full-versus-diff behavior, and baseline handling. |
| 4 | `src/capabilities/types.ts`,
`src/platform/mobile-platform-driver.ts` | Optional state fields and the
mobile partial-state result. |
| 5 | `src/cli/mm.ts` | Global parsing, exact error text, request-body
injection, and body-field precedence. |
| 6 | Changed tests, `README.md`, `SKILL.md` | HTTP and batch behavior,
CLI injection, mobile state, and caller migration guidance. |

<details>
<summary>Testing</summary>

- Fresh check at remote head `9f8803964119722b13c10e989be20aa5e035c0bc`:
`CI=true yarn vitest run src/cli/mm.test.ts
src/platform/mobile-platform-driver.test.ts
src/server/create-server.test.ts
src/server/observation-compaction.test.ts src/tools/batch.test.ts
src/tools/registry.test.ts src/tools/types/observation.test.ts
src/validation/schemas.test.ts --coverage.enabled=false --maxWorkers=2`
passes: 8 files, 683 tests. Vitest reports no type errors.
- `yarn lint` and the production type check, `yarn tsc --noEmit
--emitDeclarationOnly false -p tsconfig.build.json`, pass.
- Standalone `yarn tsc --noEmit -p tsconfig.json` fails with nine
test-fixture diagnostics already present at this remote head. These
affect Hermes, mobile backend mocks, a server test table, mock-network
result narrowing, and a screenshot fixture.
- The tests cover category defaults, explicit overrides,
last-executed-step selection, stripped handler input, full/diff
baselines, and state-free observations.
- The `Proof` and `Unit tests` sections below retain the original
historical evidence. These fresh checks do not repeat live platform
validation.

</details>

## Breaking changes

- `run_steps.includeObservations` is removed. Sending it is a validation
error (`includeObservations was removed; use observe`). The `'failures'`
mode is gone.
- Observations no longer contain `state`:
  - `StepRecordObservation` has no `state` field;
- `createDefaultObservation(testIds, nodes)` drops its first parameter.
- `ExtensionState`: `currentUrl`, `isUnlocked`, `currentScreen`,
`accountAddress`, `networkName`, `chainId` and `balance` are optional.
New field: `source?: 'stub'`.
- `MobilePlatformDriver.getAppState()` returns `{ isLoaded, extensionId,
source: 'stub' }` only.
- `shouldCollectObservations` and `shouldIncludeObservationsInResponse`
are replaced by `resolveObserveLevel(category, input, toolResult)`.
- `toolSchemas` entries are now intersections that include `observe`.
The individual `*InputSchema` exports are unchanged.

## Consumer impact

| Consumer | Change needed | Done on branch |
|---|---|---|
| Extension `test/e2e/playwright/llm-workflow/` | None. Its own
`ExtensionState` with required fields still type-checks against the
looser core type, and nothing reads `observations.state` or
`includeObservations`. Jest: 144 passed. | n/a |
| Mobile `tests/llm-workflow/` | The state snapshot capability stops
inventing `isUnlocked`/`currentScreen`/null fields: with no session it
reports `{isLoaded:false, extensionId:'', source:'stub'}`, and it omits
`currentScreen` when no known testId matches. Jest: 232 passed. |
`cryptotavares/mm-cli-v2-mobile` 35fc72cc609 |

Follow-up, not in this PR: on mobile, core `get_state` calls the driver
directly, so the Mobile capability's screen detection only runs on
browser. Phase 2 `mm state` (P2-09) covers this.

## Proof

Tested with a yalc build of this branch in the Extension (e2e build,
headless) and Mobile (dev client on iOS sim and Android emulator), on
wallet home after unlock. Before = #61 tip; its proof is reused, except
the Extension click bench, which was run on both builds back to back.

**Same command at each level** (after; bytes / totalMs):

| Platform | `none` | `diff` | `full` |
|---|---|---|---|
| ext (`click` a home tab) | **197** / 17 | 6760 / 581 | 9441 / 586 |
| iOS (`wait-for wallet-screen`) | **183** / 880 | 7495 / 2923 | 58865 /
2905 |
| Android (`click tab-bar-item-Wallet`) | **193** / 1794 | 7672 / 3671 |
1059191 / 3666 |

| Check | Result |
|---|---|
| `click --observe none` | no `observations`, `observationMs` 0, on all
3 platforms |
| `get-state --observe full` | observation with `testIds` + `a11y` only:
no `state`, no `diff` |
| `run-steps`, top-level `observe: none` / last step `observe: full` |
no observations / full tree without `diff` (all 3 platforms) |
| `run-steps` with `includeObservations` | exit 1: `includeObservations
was removed; use observe` |
| `observations.state` in any of the 55 after outputs | none |
| Mobile `get-state`, before → after | `isUnlocked: true, currentScreen:
"unknown", currentUrl: "", 4 × null` → `{isLoaded, extensionId, source:
"stub"}` |
| ext click `observationMs` p50 (bench, 5 runs, back to back) | 564 →
555 ms; bytes 7335 → 6760 (the `state` block) |
| ext smoke `click-unlock` (unlock → home) `observationMs` | 71 → 15 ms
(the re-check loop no longer runs while the screen is `unknown`) |

The bench gain is small because on home the screen is already known, so
the 500 ms re-check rarely ran. Most of the remaining ~550 ms is the
animation wait plus the a11y snapshot. P1-10 targets the animation wait.

<details>
<summary>Transcripts (ext)</summary>

```
$ yarn mm click --testid unlock-submit --observe none
{
  "clicked": true,
  "target": "testId:unlock-submit",
  "meta": { "timing": { "queueMs": 0, "toolMs": 32, "observationMs": 0, "totalMs": 33 } }
}

$ yarn mm run-steps '{"steps":[{"tool":"get_state"}],"includeObservations":"none"}'
Error: includeObservations: includeObservations was removed; use observe
exit=1

$ yarn mm --observe bogus get-state
Error: --observe requires one of: none, diff, full
exit=1
```

</details>

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

```
Test Files  46 passed (46)
     Tests  1450 passed (1450)
Type Errors no errors
All files | 96.94 | 93.52 | 94.91 | 97.12
yarn lint → exit 0
```

New or changed:
- `resolveObserveLevel`: category defaults, an explicit level wins, an
invalid level is ignored, `run_steps` last executed step and failure
fallback.
- Endpoint tests: `click` with `none`; `get_state` with `full`/`diff`;
`cdp` with `full` waits for animations; `observe` is not passed to the
handler; `/cleanup` body.
- No `getExtensionState` during observation (spy).
- Mobile `getAppState` shape.
- `withObserve`/`omitObserve`, `includeObservations` rejected, and the
CLI `--observe` parsing and injection.

</details>
cryptotavares added a commit that referenced this pull request Oct 29, 2026
…e call (#65)

> **Summary:** Reduce browser observation work with a bounded animation
wait and one in-page testId collection call. Run both functions in a
cached CDP isolated world, so Extension page-global restrictions do not
break them.

**Stack context:** mm CLI v2, Phase 1, **P1-10 + P1-12**, combined in
one PR. Base: `cryptotavares/mmcli-p1-08-abort-refs`; review
[#64](#64) first.
Followed by [#66](#66).

## Problem

Browser observations wait for every running animation, including
spinners that never finish. The old main-world check also depends on
globals that LavaMoat can restrict. Hidden tabs do not provide the
animation frames that the check needs.

TestId collection adds three Playwright calls per element:
`getAttribute`, `isVisible`, and `textContent`. Extension home therefore
needs many round trips before the accessibility snapshot starts.

These costs share the observation budget. The historical proof below
records a first Send observation that times out without testIds. This PR
changes the browser collection boundary used by observations and
`PlaywrightPlatformDriver.getTestIds`.

## Solution

Keep browser orchestration in the daemon. Move DOM inspection into
self-contained functions that execute in the main frame's isolated
world.

```mermaid
flowchart TD
    A[Browser observation] --> B{Read-only or discovery tool?}
    B -->|no| C[Wait for finite, in-viewport animations]
    B -->|yes| D[Collect testIds in cached isolated world]
    C --> D
    D -->|success| E[Map items to existing output shape]
    D -->|failure or timeout| F[Collect testIds through locator fallback]
    E --> G[Collect accessibility tree]
    F --> G
    G --> H[Return observation]

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

Yellow boxes are new.

`callInIsolatedWorld` owns the CDP session and execution context. A
`WeakMap` caches them per page. Steady-state calls send one
`Runtime.callFunctionOn` request. Protocol or session failures clear the
matching cache entry and retry once. In-page exceptions propagate
without a retry.

The isolated world shares the DOM but has separate globals. Page
functions travel as source text, so they cannot import helpers or
capture module state. Their helpers stay inside each function. The
source wrapper supplies `__name` for keepNames-transformed functions; VM
tests check the serialization boundary.

`discovery.ts` owns the failure policy. Animation timeouts and
unreadable pages end the wait. TestId call failures and timeouts use the
locator fallback. The server still checks its abort signal between
observation stages.

## What changes

| Condition or boundary | Before | After |
|---|---|---|
| Running animations | Every running animation delays collection | Only
finite animations with non-empty target boxes intersecting the viewport
delay collection |
| Animation wait budget | 3000 ms configured wait | 1000 ms in-page cap,
with 250 ms daemon grace |
| Hidden page | Animation-frame polling only | Poll with a 16 ms timer
while hidden |
| TestId collection | Three locator calls per element | One in-page call
in steady state; locator fallback on failure or a 5 s call timeout |
| TestId output | Items carry testId, tag, text, and visibility | Same
shape, order, limit, and text-preview rules; `tag` stays `element` |
| Page navigation or closed CDP session | No shared isolated-world cache
| Drop the failed entry and retry once; late failures cannot replace a
newer entry |

Collection includes hidden testIds with `visible: false`. Visibility
follows Playwright's rules, including `display: contents`, non-empty
boxes, and computed `visibility`. Zero opacity and off-screen position
do not hide an otherwise visible item.

The collector visits document matches before open shadow-root matches.
It skips empty testIds without consuming the limit. Text remains trimmed
and capped at 100 characters; null text maps to `undefined` at the
daemon boundary.

## Decisions for reviewers

| Decision | Reason |
|---|---|
| Combine **P1-10** and **P1-12** | Both improvements need the same
isolated-world execution boundary. |
| Cache one session and world per page | Avoid creating more execution
contexts on each call to a long-lived Extension page. |
| Keep page functions self-contained and exclude them from coverage
instrumentation | Injected counters or module helpers would break
source-text execution. VM tests exercise that boundary directly. |
| Keep locator fallback and fail-open settling | Observation collection
remains best-effort when CDP or page inspection fails. The 5 s cap
covers the in-page call, not the fallback. |
| Preserve the separate accessibility snapshot | This PR targets
animation settling and testIds. **P1-14** addresses the remaining
snapshot cost identified in the historical proof. |

## Review guide

Read the files in this order:

| # | File | What to check |
|---|---|---|
| 1 | `src/server/create-server.ts` | `snapshotBrowser` keeps
category-based settling, collection order, and abort checkpoints. |
| 2 | `src/tools/utils/constants.ts` | Animation cap, daemon grace, and
testId-call budget. |
| 3 | `src/tools/utils/cdp.ts` | Per-page caching, stale-entry reset
guard, single retry, exception handling, and timeout cleanup. |
| 4 | `src/tools/utils/in-page.ts` | Animation eligibility, hidden-page
polling, visibility semantics, shadow-root order, and source-text
independence. |
| 5 | `src/tools/utils/discovery.ts` | Output mapping, locator fallback,
and animation failure policy. |
| 6 | `src/tools/utils/{cdp,in-page,discovery}.test.ts`,
`src/server/create-server.test.ts` | Cache lifecycle, serialized
functions, fallback, timeout, and observation integration. |

<details>
<summary>Testing</summary>

- Fresh check at remote head `50eb3962b30ee8f5e2605dfcf307417e0282f560`:
`CI=true yarn vitest run src/tools/utils/cdp.test.ts
src/tools/utils/in-page.test.ts src/tools/utils/discovery.test.ts
src/server/create-server.test.ts src/platform/playwright-driver.test.ts
src/tools/discovery-tools.test.ts --coverage.enabled=false
--maxWorkers=2 --minWorkers=1` passes: 6 files, 317 tests.
- `yarn lint` passes. `yarn tsc --project tsconfig.build.json --noEmit`
passes for production sources.
- Standalone `yarn tsc --project tsconfig.json --noEmit` fails at the
remote head with 11 test diagnostics. One is the new
`in-page.test.ts:728` type predicate (`TS2677`); ten come from inherited
test code.
- The server suite emits an expected `EACCES` log from its read-only
log-directory fixture and passes.
- The `Proof` and `Unit tests` sections below retain the original
historical evidence. These fresh checks do not repeat the live browser
benchmarks or full coverage run.

</details>

## Breaking changes

None. The constants are internal. Observations may now be taken while an
infinite or off-screen animation is still running; that is intended.

## Consumer impact

| Consumer | Change needed | Done on branch |
|---|---|---|
| Extension `test/e2e/playwright/llm-workflow/` | None | n/a |
| Mobile `tests/llm-workflow/` | None (browser-only code paths) | n/a |

## Proof

Tested with a yalc build of this branch in the Extension (e2e build,
headless). I ran a fresh before/after bench back to back: before = #64
(7d0c565), after = this branch. Every bench has 5 runs, and the 1-minute
host load was 6–9. After a review round I re-checked the final commit
(50eb396) and got the same numbers.

| Check | Before (#64) | After |
|---|---|---|
| `list-testids` on home, toolMs p50/p95 (44 items) | 53 / 61 | **1 /
1** |
| `list-testids` JSON, home + unlock | – | same items, order, tags and
visibility. Only difference: the local chain port in 3 `text` values,
which also changes between two runs of the same build |
| Click on home (Tokens tab), observationMs p50/p95 | 562 / 571 | 515 /
517 |
| Click on Send page, observationMs p50/p95 | 21 / 32 | 13 / 13 |
| Click on account list, observationMs p50/p95 | 529 / 532 | 513 / 515 |
| First Send click of a session, observationMs | **8003** (timeout, no
testIds) | **43** (11 testIds) |
| Send → back smoke, 5 runs | 5/5, but 4/5 Send opens returned no
testIds | 5/5, all with testIds (3 runs total) |

The remaining ~515 ms on home is the accessibility-tree snapshot:
`get-state --observe full` takes ~509 ms without any settle. The settle
and testIds now add about 6 ms together. P1-14 targets that snapshot.

Known gap: the locator fallback logs only through `debugWarn`, so it is
visible only with `DEBUG=true`. In the recheck it never ran: every
`list_testids` took ~1–2 ms; the fallback takes ~50 ms.

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

```
Test Files  51 passed (51)
     Tests  1610 passed (1610)
All files | 97.31 | 94.18 | 96.35 | 97.47
yarn lint → exit 0
```

New tests:

- `in-page.test.ts`:
- each function is compiled from its own source text in a bare `vm`
context, so a stray import or closure fails the test;
- a guard checks that `toString()` has no bundler helpers other than
`__name`;
- covers the settle predicate (infinite, off-viewport, no target, hidden
page) and visibility (`display: contents`, `visibility`, zero box),
text, limit and shadow-root order.
- `cdp.test.ts`: session/world caching, retry after navigation, in-page
exceptions, timeout.
- `discovery.test.ts`: the single call, plus fallback to locators on
failure and on timeout.

</details>
cryptotavares added a commit that referenced this pull request Oct 29, 2026
…TimeoutMs cap (#71)

> **Summary:** Fix the review comments on #62, #64 and #65 in one PR on
top of the stack. The approved PRs stay unchanged.

**Stack context:** mm CLI v2, Phase 1, **P1-43**. Base:
[#69](#69), the top of
the stack.

## Problem

| Comment | Problem |
|---|---|
| [#65: off-screen
animations](#65 (comment))
| The browser settle ignored animations whose target was outside the
viewport or had zero size. A drawer sliding in, a bottom sheet or an
accordion opening from height 0 start that way, so the observation
caught them mid-move. |
| [#64: late
click](#64 (comment))
| `click` / `type` checked the request signal only before the target
wait. When the request timed out during the wait, the agent got "timed
out", but the click still ran once the target appeared. |
| [#64:
batchTimeoutMs](#64 (comment))
| `batchTimeoutMs` accepted up to 300 s, but the queue and the CLI stop
`run_steps` after 30 s. |
| [#62: refs after
`none`](#62 (comment))
| `--observe none` does not refresh refs; some refs can then click a
different element. Not documented. |
| [#62: reads and
animations](#62 (comment))
| Reads do not wait for animations, even with `--observe full`. Not
documented. |

## Solution

```mermaid
flowchart TD
    A[click / type] --> B[Wait for target]
    B --> C{Request timed out?}
    C -->|yes| D[MM_REQUEST_ABORTED, no action]
    C -->|no| E[Click / tap / type]
    E --> F[Mutating observation]
    F --> G[Wait for every running finite animation, max 1 s]
    G --> H[Read tree]

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

Yellow boxes are new or changed.

## What changes

| Commit | Change |
|---|---|
| `fix: wait for animations that start off screen or at zero size` |
`animationsSettled` waits for every running, finite animation of an
element. Infinite animations are still ignored; the 1 s cap is
unchanged. |
| `fix: do not click or type after the request timed out` |
`IPlatformDriver.click` / `type` take an optional `signal`. Both drivers
call `signal.throwIfAborted()` after the target wait, right before the
action (mobile `type` also after the focus tap). The tools pass
`context.signal` and return `MM_REQUEST_ABORTED`. |
| `fix!: cap run_steps batchTimeoutMs at 30 s` | Schema range
1000–30000, like the `cdp` `timeoutMs`. |
| `docs: …` | README, SKILL.md and CLI help: refs after `--observe
none`, reads and animations, abort limits, `batchTimeoutMs`. |

## Decisions for reviewers

| Decision | Reason |
|---|---|
| One new PR instead of changing #62/#64/#65 | Pushing to an approved PR
dismisses its approval, and the restack would change every PR above it.
|
| An action that already started is not cancelled |
`@metamask/device-mcp` takes no signal (`waitForElement`, `tapElement`,
`typeText`), and Playwright's actionability checks are part of
`locator.click`. The driver checks the signal between steps. Cancelling
inside device-mcp needs a device-mcp change (follow-up). |
| Cap `batchTimeoutMs` at 30 s instead of raising the request budget for
batches | Matches the CLI and queue default; long flows can be split
into several batches. |
| Reads stay without the animation wait (docs only) | It is a less exact
snapshot, not a wrong action. Can be revisited. |
| The 5 s testId collection cap stays | Agreed with the reviewer: watch
real usage first. |

## Breaking changes

`run_steps` rejects `batchTimeoutMs` above 30000 (validation error),
also when a server raised `requestTimeoutMs`.

## Review guide

| # | File | What to check |
|---|---|---|
| 1 | `src/tools/utils/in-page.ts` | `animationsSettled` without the
viewport/size filter. |
| 2 | `src/platform/playwright-driver.ts`,
`src/platform/mobile-platform-driver.ts`, `src/platform/types.ts` |
`throwIfAborted()` placement. |
| 3 | `src/tools/interaction.ts` | Signal passed; abort mapped to
`MM_REQUEST_ABORTED`. |
| 4 | `src/validation/schemas.ts` | `batchTimeoutMs` range and
description. |
| 5 | `README.md`, `SKILL.md`, `src/cli/mm.ts` | Docs. |

<details>
<summary>Testing</summary>

`yarn test`: 51 files, 1809 tests pass. `yarn lint` passes. Each commit
passes the tests on its own.

**Extension** (e2e fixture wallet, headless), before = #69, after = this
branch:

| Check | Before | After |
|---|---|---|
| Drawer sliding in from off screen (400 ms): observationMs | 515–516
(did not wait) | 914–929 (waited) |
| Accordion from height 0 (400 ms): observationMs | 515–516 | 916–917 |
| Home click observationMs | 513–514 | 512–515 |
| Send / account list click observationMs | 12–183 / 14–164 | 14–212 /
15–164 |
| `run_steps` click whose target appears 1 s after the 30 s queue
timeout | **clicked** | **not clicked** |

**Android** (device-mcp 0.6.0, real funds, benign taps): all smoke and
ref checks pass, same as #69.

**iOS** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref
checks pass, same as #69. (A first attempt hit the stale idb companion
after a simulator reboot, `dtuhidd connection ... invalidated`; fixed by
restarting the companion. See MetaMask/device-mcp#43 and #70.)

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

5 participants