Commit Graph

742 Commits

Author SHA1 Message Date
dependabot[bot] ec2521d850 chore(ci)(deps): bump the github-actions group across 1 directory with 5 updates
Bumps the github-actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6` | `7` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `6` |



Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/setup-python` from 6 to 7
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

Updates `actions/setup-java` from 5 to 6
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 06:12:16 +00:00
Kacper Kapuściak e1d7654af5 docs: add a Limits section to the physical iOS devices page (#1050)
## Summary

- Adds a `## Limits` section to
`packages/docs/docs/features/physical-ios-devices.mdx`, matching the
shape of the other feature pages.
- Moves the unsupported-capability bullets out of "What works" into the
new section.
- Adds limits that were undocumented: iPhone only, USB cable only,
home/volume/action buttons only, Enter and Backspace only.

## Docs

This PR is a docs-only change. `npx docusaurus build` and `npm run
format` pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
  * Clarified limitations when using physical iOS devices.
* Documented supported hardware: iPhones are available, while iPads are
not currently shown.
* Added details about USB-cable-only control, supported home, volume,
and action buttons, and available text, Enter, and Backspace keys.
* Moved simulator-specific limitations into a dedicated “Limits”
section, including unavailable rotation, shake, permission changes,
debugging, profiling, inspection, recording, and paste capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-11 11:33:33 +02:00
Ignacy Łątka b27f222386 fix(flows): refuse to record a step whose native-devtools precheck blocked (#1116)
Fixes #935.

**Cause:** a blocked native-devtools precheck is a *resolved* result, so
`flow-add-step` read "it returned" as "it ran" and wrote the step - a
`restart-app` that terminated and launched nothing became a passing
`launch:`, while the runner scores that same result an error at replay.

**Fix:** the recorder reads the runner's own
`isNativeDevtoolsBlockResult` right after the sub-invoke and fails the
call, recording nothing. Keyed on `params.command`, so it covers all
nine precheck tools, not just the `restart-app` in the report.

**Left alone:** `isDebuggerNotConnectedResult` is the same shape on the
recorder path and is filed separately as #964;
`nestedOrchestratorOutcome` is #719's scope.

**Docs:** none needed - no tool, CLI, config key or flow directive
changed, and `flow-add-step`'s description already states that an error
means nothing was recorded.

<details>
<summary>Verification</summary>

New
`packages/tool-server/test/flows/flow-record-native-devtools-gate.test.ts`
- all four block statuses on `restart-app`, the raw `tool:` spelling on
`native-full-hierarchy`, plus two negative controls (a green
`restart-app` still records `launch:`; a `gesture-tap` answering
`{status: "connect_pending"}` still records).

Discriminates - with the source hunk stashed:

```
Tests  5 failed | 2 passed (7)
AssertionError: promise resolved "{ ...(5) }" instead of rejecting
+   "message": "Step added to \"nd-rec\" flow",
+   "recorded": "1. tool: native-full-hierarchy {\"bundleId\":\"com.example.app\"}",
```

Restored:

```
Test Files  1 passed (1)
     Tests  7 passed (7)
```

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 369 files, 4839 passed, 1 skipped
- `npx prettier --check` on both files - unchanged
- eslint on the two files could not run locally
(`packages/docs/tsconfig.json` needs its own `npm ci`); CI covers it.

</details>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:56:23 +02:00
Ignacy Łątka 3dbf7bd3cb fix(screenshot-diff): classify an undecodable PNG and name the failing side (#1147)
Fixes #818

**Cause** - `decodePngFile` let the pngjs/fs error through as-is, so an
undecodable input answered with a bare library string, no failure code
(bucketed `ARGENT_UNCLASSIFIED_FAILURE`), and no hint which of the two
sides was bad - both decode in one `Promise.all`.

**Fix** - wrap the decode in a `FailureError` carrying
`SCREENSHOT_DIFF_INPUT_INVALID` / `screenshot_diff_decode_failed` and
the offending path, matching `validateInputSources` in the same tool.
`flow`'s `snapshot` goes through the same `diffPngFiles`, so it gets it
too.

**Class** - the other `PNG.sync.read` sites decode bytes the tool itself
just captured, not a caller path: `vega-screen.ts` (emulator's own
output), `flow-visual.ts` `cropPngFile` (the capture it just wrote),
`flow-pixels.ts` (deliberately soft - absence of visual evidence). Left
as-is.

**Docs** - none needed: no tool schema, CLI, config key or flow
directive changed.

<details>
<summary>Verification</summary>

Three-case table test (`test/screenshot-diff.test.ts`), each failing on
the un-fixed code with exactly the strings the issue reports:

```
non-PNG bytes  expected Error: Invalid file signature to be an instance of FailureError
truncated PNG  expected Error: There are some read requests waitn… to be an instance of FailureError
a directory    expected Error: EISDIR: illegal operation on a dir… to be an instance of FailureError
```

Proven by stashing the source fix, running the file (3 failed | 21
passed), restoring it (24 passed).

- `npx tsc --build` clean
- `npm run typecheck:tests -w @argent/tool-server` clean
- `npm test -w @argent/tool-server` -> 368 files, 4835 passed | 1
skipped
- `npx prettier --write` on both changed files - unchanged

</details>
2026-09-10 16:29:32 +02:00
Ignacy Łątka a3312cab97 fix(tool-server): admit a digit-leading bundleId in the four app-lifecycle schemas (#1101)
Fixes #1024

**Cause.** `launch-app`, `restart-app`, `reinstall-app`,
`settings-permissions` and `open-url` each carried their own copy of
`/^[A-Za-z_][A-Za-z0-9._-]*$/`, which rejects an iOS bundle id such as
`9gag.app` - with a message that says digits are allowed, so the agent
reading it has nothing to act on.

**Fix.** One shared `BUNDLE_ID_PATTERN` / `BUNDLE_ID_MESSAGE` in
`src/utils/bundle-id.ts`. The head class becomes `[A-Za-z0-9_]` (the
same class `native-devtools`' handshake validator already uses); `-` and
`.` stay out, which is the whole of the flag-injection guard the old
comments cited. The message names the head rule it enforces.

**Class check.** No copy of the pattern remains (`grep
BUNDLE_ID_PATTERN`). `open-url` was a fifth copy that landed on main
while this branch was open - the catalogue-wide test caught it.
`ACTIVITY_PATTERN` in `launch-app` / `restart-app` keeps its letter-only
head deliberately: an activity is a Java class name and cannot start
with a digit.

**Docs.** No docs change needed - `reference/tools.mdx` lists these
tools by one-line summary and documents no parameter constraints.

<details>
<summary>Verification</summary>

New `test/bundle-id-pattern.test.ts` walks the whole catalogue rather
than one tool, so a sixth `bundleId` constraint has to satisfy the same
rules. Discrimination proved by restoring the old pattern and message in
the shared module and re-running:

```
 × derives a sane MCP JSON schema with bundleId required for every action
 × launch-app: admits a digit-leading bundle id and still refuses a flag
 × launch-app: names the head rule when it rejects one
 × restart-app / reinstall-app / settings-permissions / open-url: both, each
 Tests  11 failed | 61 passed (72)
```

With the fix in place, from the worktree root:

```
npx tsc --build                                  OK
npm run typecheck:tests -w @argent/tool-server   OK
npm test -w @argent/tool-server                  434 files, 6174 passed | 1 skipped
npx prettier --write <changed files>             unchanged
npx knip                                         bundle-id.ts absent from the report
```

`npx eslint` could not run in this checkout
(`packages/docs/tsconfig.json` cannot resolve `@docusaurus/tsconfig`
without a separate `npm ci` in that workspace, a pre-existing
local-environment issue); the ESLint CI job passes.

</details>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:12:38 +02:00
Ignacy Łątka 9e196a97be fix(installer): quote the project root in the manual-install advice (#1099)
Fixes #1053

**Cause.** `installLocally`'s failure advice built `cd ${projectRoot} &&
${cmdStr}` with the root interpolated raw. A project path with a space
is cut in two when the user pastes it; one holding `$`, a backtick or
`\` still runs, aimed elsewhere.

**Fix.** `shellQuotePath` in `package-manager.ts` single-quotes the path
(escaping embedded `'`), and the advice uses it.

**Class.** This is the only discovered path interpolated into printed
shell advice - the two other "Install manually with" lines print
`cmdStr` alone. `cmdStr`'s one variable argument is `--from <tarball>`,
a path the reader typed themselves and gets back in the form they gave;
left as is.

**Docs.** No tool, CLI flag, config key or flow file changes - nothing
in `packages/docs/` needs an update.

**Verified.** New test in `install-runner.test.ts` drives a failing
local install with the root `.../My 'Project' $x`, extracts the printed
`cd`, and runs it under `/bin/sh` - `pwd` must come back as the project
root. It reproduces the issue's error on the unfixed line.

<details>
<summary>Test discriminates - fix reverted</summary>

```
 FAIL  test/install-runner.test.ts > installLocally failure handling > quotes the project root in the manual-install advice
/bin/sh: line 0: cd: /var/folders/.../argent-install-advice-UW3dHe/My: No such file or directory
 Test Files  1 failed (1)
      Tests  1 failed | 6 passed (7)
```

with the fix in place:

```
 Test Files  1 passed (1)
      Tests  7 passed (7)
```
</details>

<details>
<summary>Checks</summary>

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/installer` - clean
- `npm test -w @argent/installer` - 17 files, 556 tests passed
- `npx knip` - 30 lines, identical to the same run on the unchanged tree
- `npx eslint` on the three changed `src/` files - clean; `npx prettier
--write` applied
</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:11:36 +02:00
Ignacy Łątka b14d1fb478 fix(settings-permissions): 400, not 500, for a permission the platform cannot map (#1100)
Fixes #1032

**Cause** - both `settings-permissions` platform arms rejected an
unmappable permission with a bare `FailureError`. The HTTP dispatcher
keys the status on the error *class*, never on `error_kind`, so it fell
through to **500** and the agent read caller input as the retryable
class.

**Fix** - throw `InvalidToolInputError` instead (same message, same
`SETTINGS_PERMISSION_UNSUPPORTED` signal), which the dispatcher maps to
**400** while keeping the granular telemetry bucket. Same shape as the
keyboard backends' "no keycode for this character" arm.

Both sites of this class inside `settings-permissions` are fixed (iOS +
Android; there is no third platform arm). The two sites the issue also
names in `devices/boot-device.ts` are left alone:
`BOOT_IOS_UNSUPPORTED_HOST` (iOS asked for on a non-macOS host) and
`VEGA_ALREADY_RUNNING` (a device-state conflict a `force:true` retry
does resolve) each need a different class - a design call in its own
right, not this fix.

No docs change: `packages/docs` documents what the tool does, not
per-tool HTTP status codes.

<details>
<summary>Verification</summary>

Discriminating test - reverted just the two source files to `main` and
re-ran the suite:

```
FAIL test/settings-permissions.test.ts > settings-permissions iOS branch > notifications is rejected as unsupported without calling simctl
  expected FailureError: Permission 'notifications' … to be an instance of InvalidToolInputError
FAIL test/settings-permissions.test.ts > settings-permissions Android branch > reminders is rejected as unsupported (no Android equivalent)
  expected FailureError: Permission 'reminders' has … to be an instance of InvalidToolInputError
Tests  2 failed | 59 passed (61)
```

With the fix restored: `61 passed (61)`.

Also run from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - full package suite green
- `npx prettier --check` on the three changed files - clean
</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:49:15 +02:00
Ignacy Łątka 15e369eff5 fix(tool-server): stop asking about --remote-debugging-port on a port that answered (#1138)
Fixes #880

**Cause.** `discoverPrimaryPage`'s no-page-target branch is only reached
after `GET /json/list` succeeded, so the app provably has
`--remote-debugging-port` (a missing flag fails earlier, as
`CHROMIUM_CDP_UNREACHABLE`) - yet the message asked about the flag and
withheld the window hint its `devtools://` sibling gives.

**Fix.** One message: the endpoint answered but exposes no page target,
i.e. the app is running with no window. Same shape as the sibling branch
two lines up.

| Before | After |
| --- | --- |
| `Chromium CDP on port N reported no page targets. Is the app started
with --remote-debugging-port=N?` | `Chromium CDP on port N answered but
exposes no page target (the app is running with no window). Open an app
window and retry.` |

Why it matters: `CHROMIUM_CDP_NO_PAGE_TARGET` maps to `cdp_unreachable`,
whose Chromium guidance says "see detail" - this string *is* the detail,
and its relaunch implication leaves a second copy contending for the
single-instance lock while the windowless one still holds the port.

**Class.** The other `--remote-debugging-port` question in the file
(`fetchJson`'s connect catch) is correct - it fires only when the port
did not answer - and is left. No docs/skills change: no prose surface
quotes this string, and no tool, CLI flag, config key or flow behaviour
changed.

<details>
<summary>Verification</summary>

New test
`packages/tool-server/test/chromium-no-page-target-message.test.ts`
drives `discoverPrimaryPage` against a local HTTP server serving a
`/json/list` with no `page` entry (and, for the sibling, only a
`devtools://` page).

Discriminates - source fix stashed:

```
FAIL test/chromium-no-page-target-message.test.ts > reports a windowless app when the list holds no page target at all
AssertionError: expected 'Chromium CDP on port 55564 reported n…' not to match /--remote-debugging-port/
+ Received: "Chromium CDP on port 55564 reported no page targets. Is the app started with --remote-debugging-port=55564?"
```

Fix restored: 2 passed.

Ran from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w packages/tool-server` - clean
- `npm test -w packages/tool-server` - 4833 passed, 1 skipped, 1 failed:
`lens-tools-platform-gate.test.ts` timing out at 5000 ms, the known
full-suite/cold-cache flake. Reproduced on a clean tree and passes on
3/3 warm reruns with the fix applied; it imports nothing this change
touches.
- `npx prettier --write` on both changed files - unchanged

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:40:33 +02:00
Hubert Gancarczyk e09e200eb5 feat(flows): let a flow run select a booted remote iOS simulator (#1060)
## What

A booted remote iOS simulator is now a device a flow run can be pointed
at - both by auto-detection and by `--platform ios-remote`.

## Why

It was invisible to flow device resolution. With a remote simulator
booted alongside local ones:

```
$ argent flow run checkout
7 booted devices matched - pass --device or --platform to disambiguate.
Available devices: 79EE0744 (ios, Booted), A1E0DF35 (ios, Booted), emulator-5554 (android, device), ...
```

Three local iPhones and four Android emulators, and the remote simulator
absent entirely. Narrowing to it did not work either - `--platform
ios-remote` was rejected by the parameter's enum. The only way onto a
remote simulator was typing the full `remote:<uuid>` id.

The cause is one type. `RawDevice.platform` was typed from the tuple of
platforms an author can *write* in a flow file, which has no
`ios-remote`, so nothing forced the case to be handled and two functions
quietly fell through:

- `isBooted` hit its `default: return false`, so a remote row never
counted as booted.
- `deviceEntryId` fell through to `serial`, a field a remote row does
not carry (it uses `udid`, prefixed `remote:`). Even once counted, it
had no id - and a resolution error printed it as `? (ios-remote,
Booted)`, hiding the very id the caller needed.

## How

The tuple served two different jobs at once - what an author may write,
and what a run may be pointed at - so it is now two:

- `LAUNCH_PLATFORMS` - launch-map keys and `when:` guards. Unchanged.
- `SELECTABLE_PLATFORMS` - device selection. Adds `ios-remote`.

`ios-remote` is selectable but deliberately not writable. A flow says
what it drives, not which machine hosts the simulator, so `when: {
platform: ios-remote }` stays a parse error.

For the same reason `ios` and `ios-remote` do not overlap: `ios` selects
local simulators only. The flow file is identical either way, so which
host runs it is an explicit choice rather than something that changes
with whatever happens to be booted.

## Scope

Selection only. A remote flow run is still coordinate-only - selector
steps, `await: { idle: true }` and `snapshot: { cropOn }` need a UI tree
source that `ios-remote` does not have yet, and `launch:` maps still
have no `ios-remote` key. Those are separate changes.

## Verification

End-to-end against a booted cloud simulator (`remote:73A22194-…`, iPhone
17 Pro / iOS 26.5) driven through the branch tool-server, with three
local simulators and four Android emulators also up:

| Run | Result |
| --- | --- |
| `platform: "ios-remote"` | resolves `remote:73A22194-…`, flow passes |
| no platform | 8 booted devices listed, including `remote:73A22194-…
(ios-remote, Booted)` - named by its id, not `?` |
| `platform: "ios"` | 3 local simulators only, the remote one absent |

`npm run build` and the tool-server suite pass.

## Docs

`flow-yaml.mdx` and the `argent flow run --help` text both list
`ios-remote` and note that `ios` never picks a remote simulator.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for selecting remote iOS simulators with `--platform
ios-remote`.
* Remote simulators are automatically detected when booted and
identified by their device ID.
  * Local `ios` selection continues to target only local simulators.

* **Documentation**
* Updated command-line help and flow documentation to describe remote
iOS simulator support and platform selection behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-10 14:31:02 +02:00
Hubert Gancarczyk 4c71bc911d fix(flow): raise the flow tree's depth cap and say why targeting failed (#721)
Rebased onto `main` (was stacked on #720), with a single hunk dropped —
see
**Restack notes** at the bottom. Two commits: `a71c0f0` is exactly this
PR's own
delta from the stacked version, `54fe761` is a review pass over it on
the
post-rebase base. Squash on merge.

## Depth cap: 40 → 100

The flow selector tree capped raw `UIView` nesting at **40**. That does
not clear the invisible wrapper layers React Native stacks on every
screen — nested navigators (`RNSScreenStackView` → `RNSScreenView`,
often two deep) plus a drawer/root wrapper routinely bury on-screen
content **40–60 levels down** before the first tappable view.

In a deeply nested production app, plainly visible interactive elements
sat at depths **41–62**. So `id:` and `text:` selectors silently failed
to resolve, and only coordinate taps worked — which is the origin of
coordinate-heavy flows on those apps.

The overflow is **silent**: nothing reports "truncated", selectors just
stop matching. So the new cap carries generous headroom rather than
hugging the deepest observed measurement.

**What it costs.** The tree itself is internal to selector resolution —
consumed by `selectorToFrame`/`evaluateCondition`, never returned — so
no tool result carries it. Text derived from it does reach the agent,
though: `assertReason`'s `text` arm quotes the matched node's hoisted
`subtreeText` verbatim into a failing `assert`/`await` reason, and
nothing truncates it downstream. Descendants a depth-40 read dropped now
hoist, so that quoted string grows with the cap. That is the trade —
under the old cap those selectors did not resolve at all. Otherwise the
cap only grows the `getFullHierarchy` payload over the native-devtools
socket, which is already field-limited: ~11KB at depth 40, ~15KB at 48,
and nothing at all past the tree's real depth.

## Targeting errors that could not be acted on

**`Provide bundleId explicitly`** — `resolveNativeTargetApp`'s own
errors close with that advice. A flow selector step cannot follow it:
the call hardcodes auto-targeting. Each failure now carries the remedy
that actually exists:

- **ambiguous connected set** — foreground the intended app with
`launch-app` (which does not terminate, so the instrumentation it
already has survives), and clear the others with `xcrun simctl
terminate` (argent exposes no terminate tool, and `restart-app` would
just bring that app back to the front). The command is resolved through
`simctlTargetForUdid`, so it carries the device's own CoreSimulator
`--set` — a simulator from a configured `ios.additionalDeviceSets`
resolves — and passes the same entitlement gate an actual spawn does. An
external provider (#735) that grants `native-devtools` but withholds
`simctl` gets the clause dropped whole, rather than a command argent
itself would refuse and its device-set path quoted back.
- **a lone connected app that is not foreground** — same foreground
remedy; it is already instrumented, so a relaunch would only discard
state.
- **the state probe failed while connections are live** — which app is
missing decides the remedy. While the app the flow launched is still in
the live connections map it is merely suspended: foreground it, do not
relaunch, since a relaunch discards the state the flow built up and
cannot help when another app is the stale one. When it is *absent* from
that map it is the one thing a relaunch fixes, so the reason names it
and sends it to `restart-app` — `launch-app` cannot instrument a process
that is already gone.
- **no connected app** — this is the one state a relaunch fixes, through
`restart-app` or a flow `launch` step.

**`launch-app, or a flow launch step`** — this was wrong in a way that
wastes a cycle. `launch-app` does **not** terminate first, so against an
app already running from Metro/Expo, Xcode, or its home-screen icon it
only foregrounds that same uninstrumented process, and the next read
fails identically. Only `restart-app` (terminate + relaunch) guarantees
an instrumented launch whatever was already running.

These reasons are repeated per step — the recorder embeds one in the
warning for every captured tap, and a failing `await:` repeats it per
poll — so the two that enumerate connected apps are capped at two
entries plus a count of what was left out, and the whole set is held
under a documented ceiling by a test that iterates every branch. That
ceiling is 800 characters. It is set by the `unregistered` reason, which
measures 738 plus the bundle id, so it holds for the ids seen in
practice and not for every one: a 63-character id clears it. Every
figure quoted at `MAX_LISTED_APPS` and `MAX_TARGETING_REASON_CHARS` was
re-measured against the current code.

`FailureError` data is preserved when these messages wrap an underlying
error, so the failure code still reaches callers that switch on it.

## Recorder: role-only selectors

A tapped node with no identifier and no visible text replays on role
alone, which holds only while that element keeps winning
`selectorToFrame`'s ranking. The raised depth cap makes this more common
— an unlabeled icon the old cap truncated away, which left `nodeAtPoint`
to pick its `testID` container, is now present and is the smaller frame
under the tap. The recorder now warns instead of recording that
silently.

## Launch gate: why the wait is per bundle

`treeSourceGate` already waits for every bundle, `com.apple.*` included,
and withholds only the *verdict* for a non-injectable one. This PR
documents why that wait is load-bearing rather than a readiness
formality: it is what ties the launched bundle to the app a later
selector step auto-targets, since `resolveNativeTargetApp(api,
undefined)` never compares the two. Skipping it per bundle could hand
the next step a different app's hierarchy and still report the run
green.

The matching fallback advice lands in the no-connected-app reason: for a
`com.apple.*` bundle that never connects, drive the app with raw point
taps and `tool: await-ui-element` steps, which read the AX tree instead
of selectors.

## Restack notes

- `a71c0f0` keeps the stacked version's per-file insert/delete counts
unchanged. `54fe761` is the review pass on top: the entitlement gate
above, the launched-app-gone remedy, four comments this branch left
stale or overstated, and the re-measured figures.
- No docs update needed — nothing in `packages/docs` documents the depth
cap, the targeting reasons or the recorder warning.
- Conflict resolved in `flow-ios-tree.ts`: #944 (physical iOS support)
landed new device-tree functions in the same region. Both blocks kept —
the targeting helpers sit next to `queryFullHierarchyTree`, which they
serve; the device block follows.
- **One hunk dropped:** a 35-line test in
`flow-hidden-blank-reads.test.ts` asserting that an `exists` miss does
not quote hoisted `subtreeText`. It is written against
`compatibilityMissNote` / "typographic variant", which #720 introduces
and `main` does not have. Worth re-adding to that PR, or here once #720
lands.
- Verified on this base: `tsc --build` and `typecheck:tests` clean; 254
tests across the 4 touched test files pass; full tool-server suite 433
files / 6190 passed / 1 skipped (one run showed a single unrelated load
flake, green on a clean re-run); eslint, prettier and both knip passes
clean. All 8 CI checks green on `54fe761`.

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

https://claude.ai/code/session_014dA4nMpWrzECNhV6if4YXZ


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- iOS hierarchy inspection now supports substantially deeper views,
improving selector and condition handling for deeply nested content.
- iOS targeting failures provide clearer, actionable recovery guidance,
including relevant app and device-state details.
- System-app flows can continue successfully when apps do not establish
a connection, including coordinate-based actions.

- **Bug Fixes**
- Captured tap steps now warn when selectors rely only on an element’s
role.
- Related selector warnings are combined into a single concise warning.
  - iOS failure messages no longer include misleading recovery advice.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:30:34 +02:00
Ignacy Łątka 42317b32ed test(tool-server): flood with multi-byte output so the UTF-8 cap is pinned (#1150)
Fixes #849

**Cause:** both `runVega` overflow fakes flood with `"x".repeat(4096)` -
pure ASCII, so `Buffer.byteLength === length` and the `maxOutputBytes:
100` cap trips under either reading. Replacing both
`Buffer.byteLength(chunk, "utf-8")` in `vega-cli.ts` with `chunk.length`
leaves `vega-cli-timeout.test.ts` green 12/12.

**Fix:** flood with 60 `U+2713` instead - 60 characters, 180 UTF-8
bytes. Over the cap on bytes, under it on length, so only a byte-counted
cap still rejects.

Same class: `ndjson-socket`'s dropped-frame `bytes` had the same ASCII
fixture, and asserted it with `Buffer.byteLength` itself, so a
`raw.length` regression passed. Its frame is multi-byte with a literal
expected count.

Left alone: `LogFileWriter.fileSizeBytes` also sums `Buffer.byteLength`,
but its only assertions are `toBeGreaterThan(0)` / `toBe(0)` - the value
is not pinned at all, a wider test gap than this issue.

Tests only - no production code, no tool/CLI/config/flow change, so no
docs update needed.

<details>
<summary>Verification</summary>

All from the worktree root on `@argent/tool-server`, at base
`e353e9bd8`.

Mutation (`stdoutBytes += chunk.length`, `stderrBytes += chunk.length`,
`bytes: raw.length`) with the tests as they stand on `main`:

```
 Test Files  2 passed (2)
      Tests  18 passed (18)
```

Same mutation, with this change:

```
 × reports an unparseable frame with byte length and a sanitised preview, then continues
 × rejects + reaps when output exceeds the cap, classified as `subprocess`
 × rejects + reaps when STDERR exceeds the cap, classified as `subprocess`
AssertionError: expected 13 to be 15
AssertionError: expected 'timeout' to be 'subprocess'
AssertionError: expected 'timeout' to be 'subprocess'
      Tests  3 failed | 15 passed (18)
```

Source restored, everything green:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - `434 passed (434)` files, `6171
passed | 1 skipped`
- `npx prettier --check` on both files - clean

`npx eslint` on the two files could not run locally: it fails resolving
`packages/docs/tsconfig.json` -> `@docusaurus/tsconfig`, a pre-existing
local-install gap unrelated to this diff.

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:00:01 +02:00
Ignacy Łątka 4a5bee4a50 fix(tool-server): stop boot-electron piping the app's stdout into nothing (#1131)
Fixes #905

**Cause:** `bootElectronApp` spawned the app with `stdio: ["ignore",
"pipe", "pipe"]` and never read stdout. An unread pipe is buffered, not
dropped, so once the OS pipe buffer fills the app blocks on its next
`write(1, ...)` and CDP stops answering. The same spawn sets
`ELECTRON_ENABLE_LOGGING: "1"`, which is what makes it write there.

**Fix:** `stdio: ["ignore", "ignore", "pipe"]`. stderr keeps its pipe -
the existing forwarding listener drains it.

**Verified:** new `boot-electron-stdio.test.ts` asserts the spawned
stdio and then spawns a real child under those exact options that writes
2 MiB to stdout; it must exit. Both cases fail on the unfixed line and
pass with it. Full `@argent/tool-server` suite green (369 files, 4834
tests).

No docs change: this is the tool-server's internal spawn, no tool, CLI,
config key or flow-file surface moves.

<details>
<summary>Discrimination proof and sibling spawn sites</summary>

Reverting only the source line, both cases fail:

```
FAIL  test/boot-electron-stdio.test.ts > leaves stdout unpiped and keeps stderr piped
  expected [ 'ignore', 'pipe', 'pipe' ] to deeply equal [ 'ignore', 'ignore', 'pipe' ]
FAIL  test/boot-electron-stdio.test.ts > lets a child that floods stdout run to completion
  AssertionError: expected false to be true   (the 2 MiB child never exits)
Tests  2 failed (2)
```

With the fix: `Tests 2 passed (2)`; package suite `Test Files 369
passed, Tests 4834 passed | 1 skipped`.

Also ran: `npx tsc --build`, `npm run typecheck:tests -w
@argent/tool-server`, `npx prettier --write` on both files. Root eslint
could not run in this checkout (`packages/docs/tsconfig.json` needs
`@docusaurus/tsconfig`, which the root install skips).

Other spawns that pipe a stream, and why they are left alone:

- `argent-tools-client/src/launcher.ts` already calls
`child.stdout?.resume()` once readline stops consuming - the precedent
this fix follows.
- `blueprints/simulator-server.ts` and `blueprints/android-devtools.ts`
read stdout with readline and `rl.close()` it at settle, leaving a piped
stdout unread for the rest of a long-lived child. Same end state, but
whether either child writes to stdout after its ready marker is
unverified without a device, so it is not changed here.
- `boot-device.ts` (Windows) points stdout at a real log fd,
`preview-window.ts` ignores stdout, and the `execFile`-style spawns are
short-lived with a reader.
</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:39:57 +02:00
Ignacy Łątka 643525f372 fix(tool-server): bound a CDP HTTP probe that no caller has armed a signal for (#1132)
Fixes #895

**Cause** - the CDP discovery helpers take an optional `AbortSignal`;
`connectCdp`, the tab manager and the Electron ready-wait pass none, so
a debug port that completes the TCP handshake and never answers rides
undici's 300s headers timeout, past the 30s MCP per-attempt cap.

**Fix** - `fetchJson` arms `AbortSignal.timeout(5s)` when the caller
supplies no signal, and classifies the resulting `TimeoutError` as
`CHROMIUM_CDP_UNREACHABLE` with `network_failure: "timeout"`, so the
caller gets the `cdp_unreachable` guidance instead of an aborted call.

Fixed at the shared `fetchJson` rather than inside `connectCdp`, so
every unsignalled call site is bounded at once: `connectCdp` (x2),
`tabs.ts` (`listPageTargets`, `browserWebSocketUrl`) and
`boot-electron`'s `waitForCdpReady` (whose 30s deadline was never
re-checked once a probe stalled). Callers that arm their own signal -
`probePort` at 800ms, `flow-run`'s liveness re-probe - are unchanged.

Left alone: `CDPClient.connect`'s WebSocket handshake has no timeout
either, but it is reached only after the port has already answered CDP
twice - a different failure, worth its own issue.

**Docs** - none needed: no tool, CLI flag, config key, flow directive or
user-facing capability changes.

<details>
<summary>Repro and verification</summary>

Repro against a bare `net` server that accepts and writes nothing,
driving `connectCdp`:

```
before: STILL PENDING at 20001ms
after:  threw after 5005ms: FailureError Chromium CDP discovery:
        GET http://127.0.0.1:53169/json/version timed out.
        Something is holding port 53169 without answering CDP.
```

The new case in `failure-classification.test.ts` discriminates - with
the source change reverted it does not fail on an assertion, it hangs:

```
FAIL packages/tool-server/test/failure-classification.test.ts >
  classifies a port that accepts and never answers as CHROMIUM_CDP_UNREACHABLE
Error: Test timed out in 20000ms.
Error: Hook timed out in 10000ms.   (afterEach, waiting on the stalled socket)
```

Checks, all from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 368 files, 4833 passed, 1 skipped
- `npx prettier --write` on both changed files
- `npx eslint` on the changed source - clean

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:33:31 +02:00
Arkadiusz d6f6d3dc9c optimization(android-emulator-setup+metro-debugger): simplify instructions (#1080)
# Why

Some instructions are vague, duplicated, or they can be easily derived
from the context.

# What

This MR refines android-emulator-setup and metro-debugger skills.

# Note

Not tested.
2026-09-10 10:45:13 +02:00
Ignacy Łątka 0896ea3417 fix(registry): snapshot the listener set before an emit walks it (#1136)
Fixes #870

**Cause:** `TypedEventEmitter.emit` iterated the live `Set`, so a
listener detached by an earlier listener was skipped, and one attached
by an earlier listener ran inside the same emit. Node's `EventEmitter`
does neither - it snapshots first.

**Fix:** iterate a copy of the set (4 production lines).

**Class:** `packages/registry/src/event-emitter.ts:30` was the only site
iterating a mutable listener collection in the repo. `lens-pty.ts`'s
`observers` array is append-only (no detach API), so it cannot hit this.

**Docs:** none needed - no tool, CLI, config key, flow file or
user-facing capability changed.

<details>
<summary>Verification</summary>

New test `packages/registry/tests/event-emitter.test.ts` pins both
directions. Reverting only the source fix (`git stash push --
packages/registry/src/event-emitter.ts`) makes both cases fail:

```
FAIL  tests/event-emitter.test.ts > still calls a listener an earlier listener detached
AssertionError: expected [ 'A' ] to deeply equal [ 'A', 'B' ]

FAIL  tests/event-emitter.test.ts > defers a listener an earlier listener attached to the next emit
AssertionError: expected [ 'C', 'LATE' ] to deeply equal [ 'C' ]

Test Files  1 failed (1)
     Tests  2 failed (2)
```

With the fix restored:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/registry` - clean
- `npm test -w @argent/registry` - 8 files, 122 tests passed
- `npm test -w @argent/tool-server` - 368 files, 4832 passed / 1 skipped
(the main consumer of this emitter, run against the rebuilt registry
dist)
- `npx prettier --write` on both changed files - unchanged

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:30:06 +02:00
Ignacy Łątka f4ff77e9e8 fix(debugger): stop LogFileWriter retaining console lines no reader can reach (#1126)
Fixes #892

**Cause** - when `LogFileWriter`'s file cannot be created, `ready` stays
false for the writer's life (nothing reopens it), so `write()` pushed
every formatted line into `writeBuffer`. Both drains are closed in that
state: `flushBuffer()` returns on `!this.ready`, and `readAll()` returns
`[]` before reaching it. Up to `MAX_ENTRIES` (50 000) lines per wedged
session, unreadable and never freed.

**Fix** - drop the dead buffering path; a line with no file is not
retained. Entry counts, level tallies and clusters are tracked
separately, so `getStats()` / `getClusters()` / `debugger-log-registry`
are unchanged, as is `readAll()`, which already returned `[]` in this
state.

`writeBuffer` / `flushBuffer` existed only in this file - no sibling
writer has the same shape. No docs change: no tool, CLI flag, config
key, flow directive or user-visible behaviour changed.

<details>
<summary>Verification</summary>

New test in `packages/tool-server/test/debugger/log-file-writer.test.ts`
chmods a temp-`HOME` `~/.argent/tmp` to `0555`, writes one entry, and
asserts the writer holds no line array while the counts and clusters
still record it (skipped on Windows and under uid 0).

Without the source fix:

```
FAIL  packages/tool-server/test/debugger/log-file-writer.test.ts > LogFileWriter whose log file cannot be created > keeps no copy of a line it could not write
AssertionError: expected [ '[L:0] 2024-03-09T16:00:00.000Z LOG…' ] to deeply equal []
Tests  1 failed | 19 passed (20)
```

With it:

```
npx tsc --build                                  # clean
npm run typecheck:tests -w @argent/tool-server   # clean
npm test -w @argent/tool-server                  # 368 files, 4833 passed | 1 skipped
npx prettier --write <changed files>             # unchanged
```
</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:11:11 +02:00
Ignacy Łątka 46d968abbf style(docs): reflow the tool-server env-var table (#1153)
`prettier --check .` fails on `main`, so every push since #1151 has a
red **Format** job.

#1151 shortened the `ARGENT_SCREENSHOT_SCALE` description. That cell was
the widest in the column, so the whole table was left padded to a width
nothing occupies any more. Reflowed it with Prettier; whitespace only.

| Before | After |
| --- | --- |
| `Format` ❌ on `main` (4 consecutive pushes) | `npx prettier --check .`
→ exit 0 |

<details>
<summary>Verification</summary>

```
$ npx prettier --check .          # the exact CI step
Checking formatting...
All matched files use Prettier code style!
EXIT=0
```

Re-running `prettier --write` produces no further change (idempotent).

`git diff -w` reduces to the separator row alone; no cell content
changed. Docs still build and the table still renders:

```
$ npx docusaurus build            # packages/docs
[SUCCESS] Generated static files in "build".
BUILD_EXIT=0

$ # parsed build/docs/reference/configuration/index.html
rows=8   (header + 7), 3 cells each, all values intact
```

</details>
2026-09-09 23:02:51 +02:00
Ignacy Łątka c841def5f2 fix(native-devtools): close the TCP listener when startProxy throws (#1127)
Fixes #882

**Cause** - the TCP branch of the `native-devtools` factory binds an
ephemeral 127.0.0.1 listener and then `await`s `host.startProxy(...)`
outside any try/catch; on an `ios-remote` device that call rethrows when
the tunnel is refused, and the registry sets `node.instance` only after
`factory()` resolves, so `dispose` (which would `server.close()`) never
runs. The listener, its fd and its port leak, once per retry.

**Fix** - close the server and rethrow, matching what `dispose` does and
what the unix branch (`bindNativeDevtoolsUnixSocket`) already does on a
bind error.

**Verified** - new test drives the factory with a remote host whose
`startProxy` throws, then rebinds the port it was handed: EADDRINUSE
before the fix, free after. Full `@argent/tool-server` suite green (434
files / 6164 tests) on the branch with main merged in, `tsc --build` and
`typecheck:tests` clean.

Siblings from the same review are filed and left to their own issues:
#883 (ax-service), #884 (chromium-server), #885 (android-devtools,
tv-control).

No docs change: no tool, CLI flag, config key or flow-file surface
moves.

<details>
<summary>Repro on the unfixed source</summary>

```
$ git stash push -- packages/tool-server/src/blueprints/native-devtools.ts
$ npx vitest run packages/tool-server/test/blueprints/native-devtools-tcp-proxy-leak.test.ts

 FAIL  native-devtools-tcp-proxy-leak.test.ts > closes the listener when startProxy throws
AssertionError: promise rejected "Error: listen EADDRINUSE: address already… { …(5) }" instead of resolving
Caused by: Error: listen EADDRINUSE: address already in use 127.0.0.1:51306
```

With the fix restored: 1 passed.
</details>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:54:01 +02:00
Ignacy Łątka 08188aad11 fix(tool-server): name the bundleId alphabet launch-app enforces on Chromium (#1129)
Fixes #901

**Cause.** `launch-app`'s `bundleId` description called the value an
"arbitrary tag" on Chromium, but the field's `.regex(BUNDLE_ID_PATTERN,
…)` is validated by zod before the platform dispatch runs - so Chromium
is bound by it exactly as iOS and Android are. An agent that took the
description at its word and passed a display name (`"My App"`) got a
rejection naming a rule the description said did not apply.

**Fix.** The Chromium clause states the alphabet the regex enforces
instead of claiming the value is free-form. `BUNDLE_ID_PATTERN` and the
rejection message are untouched - the value really is inert on Chromium,
only not unconstrained.

**Class check.** The description is the sole site: over every advertised
tool schema, `launch-app.bundleId` is the only pattern-constrained
parameter whose description promised an unconstrained value
(`restart-app` / `reinstall-app` / `settings-permissions` `bundleId`,
both `activity` fields and `profiler-load.session_id` all name a form).
The new test walks the whole catalogue, so a future one has to as well.

**Docs.** No docs change needed - `reference/tools.mdx` lists
`launch-app` by one-line summary and documents no parameter constraints.

<details>
<summary>Verification</summary>

The test is appended to the existing catalogue-wide schema contract, not
a new file. Discrimination proved by stashing the source change and
re-running:

```
 × launch-app.bundleId: states a constraint rather than promising an unconstrained value
 Tests  1 failed | 84 passed (85)
```

With the fix in place, from the worktree root:

```
npx tsc --build                                  OK
npm run typecheck:tests -w @argent/tool-server   OK
npm test -w @argent/tool-server                  368 files, 4839 passed | 1 skipped
npx prettier --write <changed files>             unchanged
```

`npx eslint` could not run in this checkout
(`packages/docs/tsconfig.json` cannot resolve `@docusaurus/tsconfig`
without a separate `npm ci` in that workspace, a pre-existing
local-environment issue); the ESLint CI job covers it.

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:52:03 +02:00
Ignacy Łątka c804f88aeb fix(tool-server): give the CDP binding timeout the same recovery guidance as its sibling (#1114)
Fixes #947

**Cause.** `DEBUGGER_CDP_BINDING_TIMEOUT` rejected with a bare `Binding
response for requestId=... timed out`, while the sibling
`DEBUGGER_CDP_REQUEST_TIMEOUT` in the same file carries a full
paused-vs-frozen recovery. It is the timeout where *paused* matters
most: `evaluateWithBinding` dispatches the script with `awaitPromise:
false`, so a runtime stopped at a breakpoint accepts the evaluate and
never runs the callback.

**Fix.** Give the binding timeout the same shape of guidance -
check/resume the debugger, else restart the app, do not retry in a loop
(each attempt burns the full timeout).

**Deliberately not done:** the issue also suggests mapping the code into
`NOT_CONNECTED_CODE_MAP`. That map is only consulted by
`debugger-status` / `debugger-log-registry`, and neither calls
`evaluateWithBinding` (only `debugger-component-tree` and
`debugger-inspect-element` do), so the entry would be unreachable. The
message is the only surface an agent actually sees.

**Class check:** `evaluateWithBinding` is the only other timeout surface
in `cdp-client.ts`; `chromium-server/cdp-session.ts` has no bare timeout
message. The wording stays platform-neutral ("restart the app", not
`restart-app`) because `cdp-client` is shared with the Chromium
blueprint.

**Docs:** none needed - no tool, CLI flag, config key or flow directive
changed; this is an error-message string.

<details>
<summary>Verification</summary>

Test added to `packages/tool-server/test/metro/cdp-client.test.ts`: the
fake CDP server answers the evaluate and never fires the binding, then
the rejection is asserted for the code and each guidance clause.

Discriminates - with the source change stashed:

```
FAIL test/metro/cdp-client.test.ts > CDPClient > failure signal classification >
     an unanswered binding rejects with DEBUGGER_CDP_BINDING_TIMEOUT and paused-vs-frozen recovery
AssertionError: expected 'Binding response for requestId=req-t …' to match /paused at a breakpoint/
+ Received: "Binding response for requestId=req-t timed out"
```

Ran from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 368 files, 4833 passed, 1 skipped
- `npx prettier --write` on both changed files - unchanged
- `npx eslint` on the changed src file - clean (the test file only hits
the known local `packages/docs` tsconfig resolution error)

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:44:09 +02:00
Ignacy Łątka b926abcb84 docs(skills): list the log-file level tokens the writer actually emits (#1123)
Fixes #912

**Cause:** the flat-log table listed `WARN `, which `LogFileWriter` only
writes for the level `warn` - a level CDP never sends. `console.warn`
arrives as `warning` (written `WARNING`), `console.assert` as `assert`
(`ASSERT`), neither documented. An agent grepping §5's documented token
finds nothing and reports no warnings.

**Fix:** the `<LEVEL>` row now lists the tokens the writer emits and
states the rule (uppercased CDP level, padded to at least 5 chars, never
truncated). `LEVEL_DISPLAY` is gone: every key mapped to exactly what
the `toUpperCase().padEnd(5)` fallback already computes, so it changed
no output - it only kept `warn: "WARN "` alive as the token this table
was copied from.

| before | after |
| --- | --- |
| `` `ERROR`, `WARN `, `LOG ` `` - Uppercase, padded to 5 chars | ``
`ERROR`, `WARNING`, `LOG `, `INFO `, `DEBUG`, `ASSERT` `` - Uppercased
CDP level, padded to at least 5 chars, never truncated |

**Verified:** new test drives the real `LogFileWriter` and pins the
table to what lands on disk; it fails on the old row (3/3) and passes on
the new one. Full `@argent/tool-server` suite, `tsc --build`,
`typecheck:tests`, prettier all green.

Docs site needs no update - the level column is documented only in this
skill; `argent-react-native-app-workflow` §4.2 defers to it rather than
restating it.

<details>
<summary>Checks</summary>

Discrimination (docs change stashed, test kept):

```
FAIL  documents the token console.warn produces
  expected [ 'ERROR', 'WARN ', 'LOG  ' ] to include 'WARNING'
FAIL  documents the token console.assert produces
  expected [ 'ERROR', 'WARN ', 'LOG  ' ] to include 'ASSERT'
FAIL  documents no token a CDP level cannot produce
  `WARN ` is documented but never written
Tests  3 failed (3)
```

Restored: `Tests  3 passed (3)`.

Package suite: `Test Files 1 failed | 368 passed`, `Tests 1 failed |
4834 passed` - the one failure is `flow-type-focus.test.ts` asserting a
500 ms settle and measuring 499 ms; it passes on its own re-run and
touches nothing in this change.

```
npx tsc --build                                   # clean
npm run typecheck:tests -w packages/tool-server   # clean
npx prettier --write <changed files>              # clean
```

</details>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:55:22 +02:00
Ignacy Łątka 59daf7c67d fix(screenshot): give ARGENT_SCREENSHOT_SCALE the 0.01 floor its parameter enforces (#1151)
Fixes #816

**Cause** - `getScreenshotScale()` guarded on `n > 0 && n <= 1`, while
the `scale` parameter of the same tool is `min(0.01).max(1.0)`; a value
in `(0, 0.01)` was accepted and shrank a capture towards zero pixels, so
`screenshot-diff` refused the pair and reported `dimension_mismatch`
rather than a configuration problem. An out-of-range value was also
swallowed with no warning, so `ARGENT_SCREENSHOT_SCALE=30` silently read
as the default.

**Fix** - the guard is now `n >= 0.01 && n <= 1`, and a rejected value
is reported once per distinct value on stderr, naming the value and the
accepted range.

**Verified** - the three new floor cases and the warning assertion fail
on the unfixed function and pass with it; full `@argent/tool-server`
suite green.

Docs: `packages/docs/docs/reference/configuration.mdx` - the
`ARGENT_SCREENSHOT_SCALE` row said `0` to `1` and a `0.3` default; it
now says `0.01` to `1`, default `0.25`, and that an out-of-range value
is ignored with a warning. No other reference or feature page mentions
the variable.

Class check: `getScreenshotScale()` is the only env-to-scale parser in
the tool-server (iOS, Android, tvOS and Vega all read it), so there is
no sibling site to fix.

<details>
<summary>Discriminating-test proof and checks</summary>

Source fix reverted, test file kept:

```
FAIL  test/screenshot-default-scale.test.ts > falls back to 0.25 below the floor for "0.009"
  expected 0.009 to be 0.25
FAIL  test/screenshot-default-scale.test.ts > falls back to 0.25 below the floor for "1e-3"
  expected 0.001 to be 0.25
FAIL  test/screenshot-default-scale.test.ts > falls back to 0.25 below the floor for "0.0001"
  expected 0.0001 to be 0.25
FAIL  test/screenshot-default-scale.test.ts > names the ignored value and the accepted range, once per value
  expected "warn" to be called 1 times, but got 0 times

Test Files  1 failed (1)
     Tests  4 failed | 9 passed (13)
```

Fix restored: 13 passed.

Checks run from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 368 files, 4837 passed, 1 skipped
- `npx prettier --check` on the three changed files - clean

`npx eslint` on the changed files could not run locally: the root
install skips `packages/docs`, so its `tsconfig.json` fails to resolve
`@docusaurus/tsconfig` and the shared project parse errors out. Same
reason `npx docusaurus build` was not run for the one-cell docs table
edit.

</details>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:42:01 +02:00
Ignacy Łątka cee2433758 test(tool-server): bind the ten remaining test servers to loopback (#1142)
Fixes #853

**Cause:** `listen(0)` with no host binds every interface, so ten
ephemeral-port test servers were LAN-reachable for as long as a suite
ran.

**Fix:** pass `"127.0.0.1"` at all ten sites, matching the twenty-two
siblings that already do. Adds a guard test that scans `src/` and
`test/` for any `.listen(0)` missing the host, so a new one fails CI.

No sites left behind: `grep -rn "\.listen("` over every package turns up
only these ten, the twenty-two already-loopback sites, the unix-socket
listens in `native-devtools.ts` / `ax-service.ts` (no host applies), and
`src/index.ts` which already passes `HOST`.

**Docs:** none needed - test-only, no tool, CLI, config key, flow file,
or user-facing capability changes.

<details>
<summary>Verification</summary>

Guard test discriminates - with the ten fixes stashed it names every one
of them:

```
+ [
+   "test/http-upload.test.ts:167",
+   "test/debugger/debugger-status-not-connected.test.ts:76",
+   "test/debugger/log-registry-not-connected.test.ts:73",
+   "test/debugger/metro-cdp-harness.ts:90",
+   "test/metro/integration.test.ts:154",
+   "test/metro/legacy-runtime-guards.test.ts:68",
+   "test/metro/multi-device.test.ts:90",
+   "test/metro/teardown-log-history.test.ts:80",
+   "test/metro/vega-target-routing.test.ts:98",
+   "test/network/network-integration.test.ts:327",
+ ]
```

`http-upload.test.ts` needed two extra lines: `listen(port)` binds
synchronously, but `listen(port, host)` defers behind a DNS lookup, so
`server.address()` came back `null`. It now awaits `listening`. The
client `http.request` had no `host` (defaulting to `localhost`), and its
`error` handler is a no-op, so a failed dial would have made the
assertion pass vacuously - pinned to `127.0.0.1`. Probed that the
request still reaches the server and a partial file is really created
before cleanup:

```
PARTIALS ["argent-upload-d567a1aa-75a0-44e3-9e06-6d42544be1ee.tar.gz"]
ERR Error: socket hang up
```

Ran from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 369 files, 4833 passed, 1 skipped
- `npm run knip` - clean
- `npx prettier --write` on the changed files - no diff

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:26:51 +02:00
Ignacy Łątka e353e9bd8d docs(skills): stop promising clusters[].message is an exact grep needle (#1107)
Fixes #1001

`argent-metro-debugger` called `clusters[].message` "the exact text
which you may look for". It is a truncated (200 char), grouped (first 80
chars) prefix that keeps newlines the flat log line collapses - so `grep
-F` of it can match nothing while `count` says N.

Prose only, no code change. Nothing in `packages/docs/` claims anything
about `clusters`.

<details>
<summary>Verification</summary>

Three tests added to `log-file-writer.test.ts` pin the grouping, the
truncation and the newline mismatch. They pass on unmodified source (the
change is prose), so each was mutation-tested:

| Mutation in `log-file-writer.ts` | Result |
| --- | --- |
| `CLUSTER_KEY_LENGTH = 80` -> `200` | 1 failed |
| `entry.message.slice(0, 200)` -> `slice(0, 400)` | 1 failed |
| `entry.message.slice(0, 200)` -> `flatMessage.slice(0, 200)` | 1
failed |

`npx tsc --build`, `npm test -w @argent/tool-server` and prettier all
clean after merging `main`.

</details>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:04:44 +02:00
Ignacy Łątka e8bc673714 docs(skills): drop the breakpoint capability argent has no tool for (#1128)
Fixes #906

**Cause:** the cross-skill index in `argent-test-ui-flow` listed
**Breakpoints** first in the `argent-metro-debugger` row.
`packages/tool-server/src/tools/debugger/` ships seven tools and none
sets, lists, removes or steps over a breakpoint - no
`Debugger.setBreakpoint*` / `pause` / `resume` / `step*` call exists
outside a test mock. An agent routed there by that row finds no tool and
no explanation.

**Fix:** the row names three capabilities that do exist. A test parses
the row and rejects any capability with no tool behind it.

| before | after |
| --- | --- |
| `argent-metro-debugger` - Breakpoints, console logs, JS evaluation |
`argent-metro-debugger` - Console logs, JS evaluation, component
inspection |

**Verified:** the new test fails on the old row (`"Breakpoints" is
advertised to agents but no argent tool implements it`) and passes on
the new one; full `@argent/tool-server` suite, `tsc --build`,
`typecheck:tests` and prettier all green.

Docs site needs no update - `packages/docs/` never mentions breakpoints,
and the only other index row for this skill
(`argent-react-native-app-workflow` line 243) already lists component
inspection, console logs and JS evaluation.

<details>
<summary>Checks</summary>

Discrimination (skill row stashed, test kept):

```
FAIL  test/debugger/skill-index-capabilities.test.ts > advertises only capabilities the row's vocabulary covers
AssertionError: "Breakpoints" is advertised to agents but no argent tool implements it: expected false to be true
Tests  1 failed | 1 passed (2)
```

Restored: `Tests  2 passed (2)`.

```
npx tsc --build                                     # clean
npm run typecheck:tests -w packages/tool-server     # clean
npm test -w packages/tool-server                    # Test Files 369 passed, Tests 4834 passed | 1 skipped
npx prettier --check <changed files>                # clean
```

Class check - `grep -rn -i breakpoint packages/skills packages/docs` now
returns nothing; the surviving mentions in `packages/tool-server/src`
are state descriptions in timeout guidance ("may be frozen, or paused at
a breakpoint"), about a breakpoint the user set in their own devtools.

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:58:32 +02:00
Ignacy Łątka 2e86b1a3e0 docs(skills): leave one rule for the debugger console log file (#1121)
Fixes #907

**Cause.** `argent-metro-debugger/SKILL.md` offered `Grep`/`Read` on the
`debugger-log-registry` log file in its two lookup tables and its
numbered workflow, while the "Never `Read` the log file directly" rule
sits further down under file-format tips - so the agent hits the
permission before the prohibition. That file holds up to 50 000
unbounded console lines (`MAX_ENTRIES` in `log-file-writer.ts`), which
is exactly the context burn the rule exists to prevent.

**Fix.** Drop `Read` from every permissive mention, leaving `Grep` and
the single prohibition. Same shape fixed in the two siblings that repeat
the sentence: `argent-react-native-app-workflow/SKILL.md` and
`argent-react-native-profiler/references/diagnostic-tools.md`. A
repo-wide grep finds no other occurrence.

**Test.** A new invariant in
`packages/argent-installer/test/skills-log-file-guidance.test.ts` scans
every bundled skill markdown file and fails on any line that offers
`Read` for the log file - matching on the line or on the heading chain
above it, so the workflow step is covered too.

**Docs.** No tool, CLI, config key or flow file changed - the skills are
themselves the user-facing prose, so `packages/docs/` needs no update.

<details>
<summary>Verification</summary>

Discriminating run - source fix stashed, test kept:

```
$ git stash push -- packages/skills/skills && npx vitest run test/skills-log-file-guidance.test.ts --root packages/argent-installer
+   "argent-metro-debugger/SKILL.md:52: | `debugger-log-registry` ... Then use `Grep`/`Read` on the flat log file for details. ..."
+   "argent-metro-debugger/SKILL.md:92: 2. **Search the file** using `Grep` or `Read` with patterns from the response."
+   "argent-metro-debugger/SKILL.md:133: | Console log overview | `debugger-log-registry` (summary + log file path for `Grep`/`Read`) |"
+   "argent-react-native-app-workflow/SKILL.md:162: ... then `Grep`/`Read` to search. ..."
+   "argent-react-native-profiler/references/diagnostic-tools.md:27: ... Use `Grep`/`Read` on the log file to filter by level ..."
 Tests  1 failed (1)
```

Restored, all green:

```
$ npx tsc --build                                  # exit 0
$ npm run typecheck:tests -w @argent/installer     # exit 0
$ npm test -w @argent/installer                    # Test Files 18 passed, Tests 556 passed
$ npx prettier --write <changed files>             # clean
```

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:57:34 +02:00
Ignacy Łątka 7b8c38b49b docs(skills): say the [L:<id>] log anchor must be matched literally (#1120)
Fixes #916

**Cause:** the flat-log table calls `[L:42]` a "unique grep anchor" and
never says to match it literally - unescaped, `[L:42]` is a character
class, and since every log line starts with `[L:`, it matches the whole
file.

**Fix:** table cell points at the search rule, plus one bullet telling
the agent to use `grep -F` (or `\[L:42\]`) for bracketed text like
`[L:<id>]` and `[object Object]`.

**Verified:** wrote 46 entries through the real `LogFileWriter` and ran
both engines against the file - `grep`/`rg` with the bare anchor return
46 lines, `-F` returns 1. Prose-only change to one SKILL.md, so no test
applies; `npx tsc --build` and `prettier --check` are green. No
docs-site page documents the log format or the anchor, so
`packages/docs` needs no update.

<details>
<summary>Repro output</summary>

```
$ head -2 repro.log
[L:0] 2026-03-17T14:31:00.000Z LOG   - | filler 0
[L:1] 2026-03-17T14:31:00.000Z LOG   - | filler 1

lines: 46
grep -c '[L:42]'          -> 46
grep -cF '[L:42]'         -> 1
grep -c '\[L:42\]'        -> 1
rg   -c '[L:42]'          -> 46
rg   -cF '[L:42]'         -> 1
grep -c '[object Object]' -> 46
grep -cF '[object Object]'-> 1
```

Entry 42 is the only `TARGET ENTRY FORTY TWO` line; `[object Object]`
appears in exactly one message.

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:39:55 +02:00
Ignacy Łątka c8d564497d fix(tool-server): bound the device-shutdown subprocesses (#1118)
Fixes #945

**Cause:** the four shutdown spawns in `utils/device-shutdown.ts`
(`xcrun simctl shutdown`, `adb emu kill`, in both `shutdownOwnedDevice`
and `shutdownDevice`) were awaited with no `timeout`, so a wedged adb
daemon or CoreSimulatorService leaves the await unsettled forever - no
error, no reap, teardown hangs.

**Fix:** pass `{ timeout: 30_000, killSignal: "SIGKILL" }` at all four
sites - 30s is `runAdb`'s ceiling, SIGKILL because both an adb client
blocked on its daemon and a simctl blocked on CoreSimulator ignore
execFile's default SIGTERM (`ADB_KILL_SIGNAL` / `SIMCTL_KILL_SIGNAL`
document this).

Docs: none needed - no tool, CLI, config key or user-facing behaviour
changes.

<details>
<summary>Scope, tests, checks</summary>

**Left alone (different shape, not the same class):**
`tools/devices/boot-device.ts` `shutdown`/`boot`/`bootstatus`,
`restart-app`, `reinstall-app` - tool-level flows with their own error
handling and much longer legitimate budgets; a single 30s shutdown
ceiling would be wrong there.

**Test discriminates.** `test/device-shutdown.test.ts` now asserts the
options object on every dispatch. With the source change stashed, 5 of
13 fail - one per production call site:

```
 × iOS -> xcrun simctl shutdown <udid>
 × Android -> resolved adb -s <serial> emu kill
 × Android with unresolvable adb -> falls back to bare "adb"
 × iOS -> ok:true and simctl argv
 × Android emulator -> ok:true and resolved adb emu kill
 Tests  5 failed | 8 passed (13)
```

Restored: 13 passed.

**Checks (worktree root):**
- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 368 files, 4832 passed, 1 skipped.
One run out of four had a single unrelated flake that did not reproduce
on re-run.
- `npx prettier --write` on both changed files
- `npx eslint` on both changed files - only the pre-existing
`packages/docs` tsconfig resolution error this checkout has
</details>

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Device shutdown commands now stop waiting after 30 seconds if the
underlying process becomes unresponsive.
- Stalled shutdown processes are forcefully terminated to prevent
shutdown operations from hanging indefinitely.
- Applies consistently to both iOS simulators and Android emulators,
including user-initiated and automatic shutdown flows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:31:05 +02:00
Ignacy Łątka 8b2ca0511d test(configuration-core): guard the detached tmpdir removal in secrets tests (#1086)
Fixes #1075

**Cause** - both `argent-detached-` tests removed their temp dir as the
last unguarded statement of the test body, so a failing assertion above
skipped it (the file's `afterEach` covers `homeDir`/`projectDir` only).

**Fix** - `try/finally` around each body, the form `flags.test.ts` in
the same package already uses.

**Verified** - with both assertions deliberately broken and a private
`TMPDIR`: 2 stranded `argent-detached-*` before, 0 after, same 2 test
failures either way. Package suite green (122 passed), `tsc --build`,
`typecheck:tests`, prettier.

Class: the same shape in `argent-installer/test/mcp-configs.test.ts` (11
sites) and `tool-server` is tracked separately by #1074 and #1077, left
to those.

No docs change - test-only.

<details>
<summary>Repro output</summary>

```
--- unfixed: Tests  2 failed | 17 passed (19)
--- unfixed: residue: 2
--- fixed:   Tests  2 failed | 17 passed (19)
--- fixed:   residue: 0
```

```
npm test -w @argent/configuration-core
 Test Files  5 passed (5)
      Tests  122 passed (122)
```
</details>

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved test cleanup to ensure temporary files and directories are
removed even when assertions fail.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:26:55 +02:00
Ignacy Łątka b570f4c484 perf(ci): take typechecking off the unit-test critical path (#1110)
`Unit Tests` is the entire PR wall clock: **468s of run + 61s of
queue**, while the other five gating jobs finish in 7-118s. Typechecking
is 64s of that, sits in series ahead of the tests, and needs none of
their output.

| | before | after |
|---|---|---|
| typecheck (64s) | serial, inside the test job | own job, runs
alongside - **116s, parallel** |
| npm cache | none on any gating job | `cache: npm`, except knip |
| superseded PR runs | keep holding a runner | cancelled |

This is scaffolding, not the fix. The job is dominated by
`tool-server/test/flows`, and that needs its own PR - measurements and a
rejected shortcut below.

`Typecheck` is a new check. It needs adding to the required status
checks on `main`, or typecheck failures stop blocking merge.

<details>
<summary>Where the 8 minutes is</summary>

`Unit Tests` run `34367669322`, per-step:

| step | time |
|---|---|
| setup + checkout + node | 8s |
| npm ci | 12s |
| tsc --build | 29s |
| Typecheck tests (13 serial `tsc`) | 62s |
| **Run tests** | **350s** |
| everything else | 5s |

`Run tests`, per package: tool-server 317.7s, the other 12 packages 28s
combined.

Inside tool-server (368 local test files): `test/flows` is 591s across
54 files, everything else 51s across 314. Half the suite is 5 files; 80%
is 13, all flows.

Those files drive the flow engine through its real `SETTLE_TIMEOUT_MS`
(3000), `DEFAULT_ACTION_TIMEOUT_MS` (7500) and `POST_LAUNCH_SETTLE_MS`
(1500). The five worst run in isolation at **87.4s wall, 7% CPU**.
`flow-gesture-settle.test.ts` is 37 tests at a 3.02s median - the settle
window waited out per test.

</details>

<details>
<summary>npm cache, per job (install step, against the uncached
baseline)</summary>

| job | before | after |
|---|---|---|
| ESLint (docs tree) | 26s | 16s |
| Prettier check | 17s | 11s |
| Unit tests | 12s | 10s |
| Knip | 6s | 8s + 1s save |

Knip installs with `--ignore-scripts` and pulls the least of any job
here, so restore and save cost more than the download they replace. It
keeps the uncached path.

</details>

<details>
<summary>Over-subscribing the worker pool: tried on this PR,
reverted</summary>

Flooring `maxWorkers` at 8 was in the first commit here. On this PR's
own CI run it halved the suite - **317.7s -> 152.1s** - and broke one
test.

`flow-composition.test.ts` went **26.5s -> 86.8s** and blew the 60s
budget it sets for itself. It pumps a fake clock in a busy loop rather
than sleeping, so it is CPU-bound where the rest of `test/flows` is
idle, and two workers per core starve it superlinearly. Suite-wide,
`import` went 106s -> 210s for the same reason.

Reverted in the second commit. Over-subscription is only a way to hide
the sleeping; once the flow tests stop waiting in real time, one worker
per core is the right number and nothing is starved.

</details>

<details>
<summary>What actually reaches 1 minute</summary>

Vitest parallelises per file and `flow-gesture-settle.test.ts` alone is
86.5s, so bin-packing the real per-file durations gives 161s at 4
workers and 87s at 8, 16, 32 or 64. **No runner size or CI matrix shard
beats ~87s of test time.**

Getting under a minute needs the flow engine's timing made injectable,
with those tests injecting small values. 13 flow files assert absolute
durations and need individual attention.

Two shortcuts do not work:

- Scaling the engine's timing constants 10x down reaches 131s of test
time and 52.9s wall at 4 workers, but is unstable: a rerun of the same
config hung 3 files to ~600s. The fixtures' own values (`readDelayMs =
400`, flow `timeout: 600`, `DEFAULT_LONG_PRESS_MS` 800) do not scale
with it, so the relationships invert.
- `vi.useFakeTimers({ shouldAdvanceTime: true })` passes 37/37 but gives
no speedup - `shouldAdvanceTime` advances fake time 1:1 with real time
by design.

`flow-composition.test.ts` already demonstrates the pattern that does
work: fake clock plus an explicit pump loop.

</details>

Not caching `dist/`: this repo has a history of dist-skew making
assertions pass vacuously, and a stale build artifact is a worse failure
than a slow job.

No docs update needed: CI-internal, no user-facing capability changed.
2026-09-09 20:18:12 +02:00
Ignacy Łątka 3e5f198326 test(tool-server): scope the home the external-device suites discover against (#1093)
Fixes #1072

**Cause** - `external-devices.test.ts` deletes both
`ARGENT_DISABLE_DEVICE_PROVIDERS` and `ARGENT_DEVICE_PROVIDERS` in its
`beforeEach`, so any test that writes no descriptor of its own has
`discoverProviders()` read the developer's real `~/.argent/providers`.

**Fix** - call `scopeTempHome()` at file top level, so that fallback is
a fresh, run-owned directory. `external-attach.test.ts` has the
identical hook and gets the same guard.

**Verified** - planting a live provider descriptor claiming the suite's
`IOS_UDID` fails 3 tests before, passes after; the new test fails
deterministically with the `scopeTempHome` call removed, naming the real
home in its message. Full `@argent/tool-server` suite green (6164
passed).

No docs change: test-only, no tool, CLI, config key or user-facing
capability moves.

<details>
<summary>Repro and check output</summary>

Repro (a descriptor in `~/.argent/providers` claiming the udid the
fixtures use, plus a live server on its `apiUrl` so it survives the
discovery probe):

```
 × returns nothing when no provider is registered at all
 × is the identity for an ordinary device and performs no I/O
 × leaves an ordinary udid and the default set untouched
 Tests  3 failed | 104 passed (107)
```

Same planted home, after:

```
 Test Files  2 passed (2)
      Tests  119 passed (119)
```

The added test discriminates - with `scopeTempHome(HOME_PREFIX)` removed
and no descriptor planted at all:

```
 × discovers against a providers directory this run owns
AssertionError: expected '/Users/<me>/.argent/providers' to contain 'argent-external-devices-home-'
 Tests  1 failed | 107 passed (108)
```

Checks from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - 433 files, 6164 passed, 1 skipped
- `npx prettier --write` on the three files - unchanged

Sibling suites left alone: the other nine tool-server files that delete
the discovery guard (`list-devices`, `metro-port`,
`external-js-debugger`, `external-native-devtools`,
`native-profiler-external-device`, `android-profiler-external-device`,
`launch-restart-external-claim`, `simulator-watcher-external-claim`,
`boot-device-external-refused`) set `ARGENT_DEVICE_PROVIDERS` in the
same hook, which replaces the directory scan outright. All nine stay
green under the planted home.
`argent-cli/test/providers-command.test.ts` and
`device-providers/test/read.test.ts` already stub `HOME`/`USERPROFILE`
themselves.

</details>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:17:37 +02:00
Ignacy Łątka fd978f302d test(tool-server): stop a failed beforeEach from throwing again in teardown (#1091)
Fixes #1076

**Cause** - suites that redirect `os.tmpdir()` for a whole file hoist
the restorer, and the scratch directory it points at, into uninitialized
`let`s that `beforeEach` assigns partway through. Vitest still runs
`afterEach` when `beforeEach` throws, so teardown calls `undefined()`
and hands `undefined` to `fs.rm` - bogus `TypeError`s reported beside
the real failure, and a reader chases the wrong one.

**Fix** - initialize what the teardown reads (`= () => {}` / `= ""`;
`fs.rm("", { force: true })` is a no-op), plus a guard in
`tmpdir-env.test.ts` that scans the suite for the shape so a new
`redirectTmpdir` caller cannot reintroduce it.

**Verified** - injected a throw ahead of the assignment in each affected
`beforeEach`: before, the teardown `TypeError` rode alongside the
injected error; after, only the injected error is reported. The guard
lists all 9 declarations against `origin/main` and passes with the fix.
`npx tsc --build`, `typecheck:tests -w @argent/tool-server`, and the
full package suite (433 files, 6164 tests) are green.

Docs: none - test-only change, no tool, CLI, config key, flow file or
user-facing capability touched.

<details>
<summary>Repro, before and after</summary>

`packages/tool-server/test/react-profiler/dump.test.ts`, throw injected
as the first statement of `beforeEach`:

Before:
```
⎯⎯⎯⎯⎯⎯ Failed Tests 17 ⎯⎯⎯⎯⎯⎯⎯
Error: SIMULATED mkdtemp failure before assignment
TypeError: restoreTmpdir is not a function
```

With only the restorer initialized - the second half of the same hook
still throws:
```
Error: SIMULATED mkdtemp failure before assignment
TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined
```

After (same for `screen-recording`, `profiler-load-list-e2e`,
`native-profiler-freshness-e2e`, `flow-visual`):
```
Error: SIMULATED failure before assignment
```
</details>

<details>
<summary>Guard against origin/main</summary>

```
- []
+ [
+   "android-perfetto/native-profiler-freshness-e2e.test.ts: let tempDir: string;",
+   "android-perfetto/native-profiler-freshness-e2e.test.ts: let restoreTmpdir: () => void;",
+   "android-perfetto/profiler-load-list-e2e.test.ts: let scratch: string;",
+   "android-perfetto/profiler-load-list-e2e.test.ts: let restoreTmpdir: () => void;",
+   "flows/flow-visual.test.ts: let tmpDir: string;",
+   "react-profiler/dump.test.ts: let restoreTmpdir: () => void;",
+   "react-profiler/dump.test.ts: let scratch: string;",
+   "screen-recording.test.ts: let restoreTmpdir: () => void;",
+   "screen-recording.test.ts: let scratch: string;",
+ ]
```
</details>

<details>
<summary>Class check - what was left alone</summary>

- `packages/telemetry/test/index.test.ts:89` `let restoreOptOut: () =>
void;` - same shape, no window: the assignment is the first statement of
the file's first `beforeEach`, so nothing can throw ahead of it.
- Roughly 90 other hoisted `let`s across tool-server tests are read in
`afterEach` without an initializer (`tmpDir`, `handle`, `exitSpy`, ...)
in suites that never redirect the tmpdir. That is a repo-wide pattern
for a lint rule, not for this fix; the guard is scoped to the
`redirectTmpdir` suites the issue reports.
- Local `npx eslint` on the changed files could not run:
`packages/docs/tsconfig.json` fails to resolve `@docusaurus/tsconfig`
because the root install skips that workspace. Pre-existing and
unrelated; CI's ESLint job is green on this branch.
</details>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:17:23 +02:00
Ignacy Łątka 1e851f72a0 test(ios-device-runner): pin USERPROFILE alongside HOME so the fixtures redirect on Windows (#1090)
Fixes #1069

**Cause:** `os.homedir()` reads `USERPROFILE` on Windows and `HOME`
elsewhere. The two `ios-device-runner` suites pinned `HOME` only, so on
Windows the redirect was inert and they built into
`%USERPROFILE%\.argent\ios-device-runner\` in the developer's real home.

**Fix (test-only):** pin `USERPROFILE` at the same directory as `HOME`
in both fixtures, and add it to the saved record so the existing
delete-or-assign restore puts it back. This is what every other
`HOME`-swapping suite in the repo already does (`avd-snapshot`,
`android-binary`, `adb-resolve-avd-path`, ...); these two files were the
only ones left.

**Docs:** none needed - no tool, CLI, config key, flow file or
user-facing capability changes.

<details>
<summary>Class check, discrimination proof and checks</summary>

Every file assigning `process.env.HOME` in `packages/`, and whether it
also pins `USERPROFILE`:

```
$ for f in $(grep -rl "process\.env\.HOME *=" --include="*.ts" packages/); do echo "$(grep -c USERPROFILE $f)  $f"; done
...
0  packages/tool-server/test/ios-device-runner-artifact.test.ts
0  packages/tool-server/test/ios-device-runner-launch.test.ts
```

The other 20 already pin both, so the class is exactly these two files.
Not moved onto `test/helpers/temp-home.ts`'s `scopeTempHome`: it is a
file-level `beforeEach`, while both fixtures swap the home mid-test
alongside `PATH` (and `ARGENT_IOS_RUNNER_PROJECT`), and
`withEnsureEnv`'s per-test home name is its first argument at 11 call
sites.

Discrimination - with only the two `process.env.USERPROFILE = ...`
assignments deleted, tests kept:

```
FAIL  test/ios-device-runner-launch.test.ts > launchRunner > resolves with the launched child and per-device log and bundle paths
AssertionError: expected 'PORT=unset ARGS=test-without-building...' to contain 'HOME=/var/.../argent-runner-launch-QkW7KQ USERPROFILE=...'
+ HOME=/var/folders/.../argent-runner-launch-QkW7KQ USERPROFILE=unset

FAIL  test/ios-device-runner-artifact.test.ts > ensureRunnerArtifact > redirects os.homedir() under both names it consults

 Test Files  2 failed (2)
      Tests  2 failed | 25 passed (27)
```

Restored, both pass. Checks run from the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - `Test Files 433 passed (433)`,
`Tests 6164 passed | 1 skipped`
- `npx prettier --write` on both files - unchanged

</details>

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:17:14 +02:00
Ignacy Łątka d45306d7f3 test(tool-server): reap three temp dirs an assertion failure strands (#1089)
Fixes #1077

**Cause** - three tool-server tests remove their temp dir as the *last
statement of the test body*, so any assertion above it throwing abandons
the directory in the shared system temp dir.

**Fix** - a module-scope list pushed to at the mint site and drained in
an `afterEach` (the shape #1057 used), replacing the trailing `rm`.
`render-annotations.test.ts` had no hook at all;
`workspace-reader.test.ts`'s describe-level `afterEach` becomes
redundant once `createTempDir` registers every dir it hands out, so it
goes.

Test-only. No tool, CLI, config key, flow file or user-facing capability
changes, so no docs update.

**Verified** - break one assertion per file, run under a scoped
`TMPDIR`, list what survives.

| file | before | after |
|---|---|---|
| `metro/source-resolver-empty-root.test.ts` | `real-app-ouDGWh` |
*(none)* |
| `react-profiler/render-annotations.test.ts` |
`argent-render-annotations-…zmt1onhl4wj`, `…sriq0gyjyo` | *(none)* |
| `workspace-reader.test.ts` | `ws-reader-test-SIWls6` | *(none)* |

Same failure count on both sides (1, 2, 1) - only the leftovers differ.

<details>
<summary>Repro, both sides</summary>

Mutations applied to each file (`git show HEAD:<path>` for the "before"
column, the branch for "after"):

```
sed -i '' 's/expect(out).toContain("IN_PROJECT");/expect(out).toContain("NOPE");/' \
  test/metro/source-resolver-empty-root.test.ts
sed -i '' 's/expect(after50).toContain("(t=192.3s)");/expect(after50).toContain("(t=999.9s)");/;
           s/expect(report).not.toMatch(\/> After:\/);/expect(report).toMatch(\/> After:\/);/' \
  test/react-profiler/render-annotations.test.ts
sed -i '' 's/expect(snap.lockfile).toBe(lockName);/expect(snap.lockfile).toBe("NOPE");/' \
  test/workspace-reader.test.ts
```

Each run, from `packages/tool-server`:

```
d=/tmp/scope/<label>; rm -rf $d; mkdir -p $d
TMPDIR=$d TMP=$d TEMP=$d npx vitest run <file>
ls -A $d
```

Before (unfixed, mutated):

```
unfixed_sr:  Tests 1 failed | 4 passed   ->  node-compile-cache  real-app-ouDGWh
unfixed_ra:  Tests 2 failed              ->  node-compile-cache
                                             argent-render-annotations-1788965985023-zmt1onhl4wj
                                             argent-render-annotations-1788965985027-sriq0gyjyo
unfixed_wr:  Tests 1 failed | 34 passed  ->  node-compile-cache  ws-reader-test-SIWls6
```

After (this branch, same mutations): identical failure counts,
`node-compile-cache` only.
</details>

<details>
<summary>Checks</summary>

From the worktree root:

- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - `368 passed (368)` files, `4832
passed | 1 skipped`
- `npx prettier --write` on the three files - unchanged

Class sweep: scanned every `*.test.ts` under `packages/tool-server/test`
for an `rm`/`rmSync`/`unlink` as the final statement of an `it` body.
Besides these three, only `react-profiler/dump.test.ts` and
`screen-recording.test.ts` match, and both already point `os.tmpdir()`
at a per-test scratch reaped in `afterEach`, so their unlinks strand
nothing. The same shape in other packages is #1074 / #1075 and is left
to those.
</details>

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Tests**
- Improved test cleanup by consistently removing temporary and debug
directories after each test.
- Consolidated cleanup handling across source resolution, profiler
annotations, and workspace reader tests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:17:00 +02:00
Ignacy Łątka e5de0802ed test(installer): stop 11 foreign-config tests stranding their temp dir on a failed assertion (#1087)
Fixes #1074

**Cause** - 11 tests in `describe("installer preserves foreign MCP
config")` remove their temp dir as the last unguarded statement of the
test body, so any assertion above it throwing strands the directory; the
file's `afterEach` only covers `tmpDir`.

**Fix** - mint those dirs through a registry drained in the file's
`afterEach`, the shape already used by `tool-server`'s
`native-devtools-env.test.ts`. Test-only; no production code and no docs
change (no tool, CLI, config key, flow file or user-facing capability
moved).

**Verified** - break the same assertions on both sides, run the file
with `TMPDIR` scoped to an empty dir: identical 25 failures, 12 stranded
dirs before, 0 after. Full installer suite 555 passed, `tsc --build` and
`typecheck:tests` clean.

<details>
<summary>Mutation repro - before vs after</summary>

Mutation (5 assertions, covering both the `argent-fc-` and
`argent-fc-home-` sites):

```
sed -i '' \
  -e 's/expect(adapter.remove(configPath)).toBe(true);/expect(adapter.remove(configPath)).toBe(false);/' \
  -e 's/expect(after.mcpAllowlist).toContain("argent:\*");/expect(after.mcpAllowlist).toContain("nope:*");/' \
  -e 's/expect(after.mcpAllowlist).toEqual(\["other:\*"\]);/expect(after.mcpAllowlist).toEqual(["nope:*"]);/' \
  -e 's/expect(fs.existsSync(permPath)).toBe(false);/expect(fs.existsSync(permPath)).toBe(true);/' \
  -e 's/expect(after).not.toHaveProperty("mcpAllowlist");/expect(after).toHaveProperty("mcpAllowlist");/' \
  test/mcp-configs.test.ts
d=/tmp/fc; rm -rf $d; mkdir -p $d
TMPDIR=$d TMP=$d TEMP=$d npx vitest run test/mcp-configs.test.ts
ls $d
```

Both runs: `25 failed | 214 passed (239)`.

| | leftovers in the scoped temp dir |
|---|---|
| `main` |
`argent-fc-{acrLbH,EkKAUK,O0pfPk,O55Nmh,XfBO0s,XOqFC4,yf8Zr9,Z1q7T8}`,
`argent-fc-home-{F0R6QZ,shaUd9,v9FiKZ,zEhAc8}` - 12 |
| this branch | *(none)* |

Unmutated on this branch: `239 passed`, scoped temp dir empty.
</details>

<details>
<summary>Checks</summary>

```
npx tsc --build                                   # clean
npm run typecheck:tests -w @argent/installer      # clean
npm test -w @argent/installer                     # 17 files, 555 passed
npx prettier --write packages/argent-installer/test/mcp-configs.test.ts   # unchanged
```

`npx eslint` on the file cannot run in this checkout - it fails at
`packages/docs/tsconfig.json(2,14): File '@docusaurus/tsconfig' not
found`, a pre-existing local-only issue unrelated to the diff.
</details>

<details>
<summary>Class sweep</summary>

Every other `rmSync` of a minted temp dir under
`packages/argent-installer/test/` and the same `rmSync(dir, ...)` shape
in `packages/tool-server/test/` already sits in a hook:
`init.test.ts:86,152` (`afterEach`), `preview-ui-dotfile.test.ts:28`
(`afterAll`), `vega-cli-timeout.test.ts:164` (`afterAll`),
`native-devtools-env.test.ts:12` (`afterEach`, the registry pattern
copied here). `mcp-configs.test.ts:1891` removes a pre-existing config
file, not a minted dir.
</details>

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
  * Improved temporary directory cleanup during installer tests.
* Temporary directories are now cleaned up automatically after each
test, including when assertions fail.
* Consolidated repeated cleanup logic for more consistent test
isolation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:16:49 +02:00
filip131311 02fd22e9d6 chore: bump all versions to 0.25.0 (#1083)
Bumps every workspace package, `package-lock.json`, and both version
fields in `server.json` from 0.24.0 to 0.25.0.

- `npm version 0.25.0 --workspaces --no-git-tag-version
--workspaces-update=false`
- lockfile regenerated with `npm install --package-lock-only
--ignore-scripts` on Node 24 / npm 11 (version-only diff)
- `npm run check:versions` passes

No docs update needed.

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

https://claude.ai/code/session_01XdSDvFDvuU1iyz7HPjcHMV

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v0.25.0
2026-09-09 15:00:24 +02:00
Ignacy Łątka 8dd2621968 docs: cut the README to the essentials (#1082)
The README duplicated the docs site - install prerequisites, a Linux
emulator tuning essay, the full CLI and editor tables - burying what
Argent actually is. 17.4 KB -> 3.5 KB (5.0x), no information lost:
everything cut already lives in the docs, now linked.

| | Before | After |
| --- | --- | --- |
| Size | 217 lines / 17404 chars | 43 lines / 3477 chars |
| Sections | 9 (incl. a ~4500-char Linux emulator section, CLI table,
editors table) | 6 |
| Media | header, 3 ads, showcase gif, SWM logo | unchanged |
| Legal | Privacy + License | unchanged (License verbatim) |

<details>
<summary>Where the removed content lives</summary>

| Removed | Docs page |
| --- | --- |
| Prerequisites, Linux emulator tuning, `--local` team install, manual
install |
[fundamentals/installation](https://docs.swmansion.com/argent/docs/fundamentals/installation)
|
| Supported platforms table |
[fundamentals/supported-platforms](https://docs.swmansion.com/argent/docs/fundamentals/supported-platforms)
|
| CLI reference table |
[reference/cli](https://docs.swmansion.com/argent/docs/reference/cli) |
| Supported editors table |
[reference/editors](https://docs.swmansion.com/argent/docs/reference/editors)
|

Verified: `prettier --check` passes; all five outbound links return 200;
`LICENSE.txt`, `Telemetry.md` and `assets/showcase.gif` exist at the
referenced paths.

No docs changes needed - the docs site already covered every removed
section.
</details>

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

https://claude.ai/code/session_01YNGjiLLCf4cg3LXx4SCeUC

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:37:59 +02:00
Arkadiusz 9d512ce933 optimization: simplify flow-run description (#954)
# Why

It is reported that benchmark results are getting worse and the
`flow-execute` description has the largest optimization potential. The
description field of MCP tools should describe what the tool does and
when to call it, not reveal implementation details.

<img width="1195" height="382" alt="Screenshot 2026-08-25 at 12 45 30"
src="https://github.com/user-attachments/assets/d83ba9c5-5e4f-4c65-ba3a-4af0588e5faa"
/>

# Warning

Not tested. I'd have to run this change twice against the benchmark repo
but that feels like overkill. I am not sure about behavioral
regressions, relying on CI here.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Documentation**
- Updated the Flow YAML reference to streamline directive documentation.
- Revised the flow execution tool’s description with a concise overview
of supported flow sources and per-step reports.
- Clarified documentation around flow behavior, including warning
handling and project-root configuration.
- Removed outdated or overly detailed statements from the published
guidance to keep the reference accurate and easier to scan.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 14:21:30 +02:00
coderabbitai[bot] 3ca9cae91d 📝 CodeRabbit Chat: Disable CodeRabbit High-Level Summaries (#1081)
Code changes was requested by @latekvo.

*
https://github.com/software-mansion/argent/pull/1066#issuecomment-5601188159

The following files were modified:

* `.coderabbit.yaml`

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-09 13:44:59 +02:00
Ignacy Łątka 16ad44bb0e test(telemetry): pin the wire format and what a dead collector costs (#736)
Two test files that look at what #570's exporter actually *does*, plus
the configuration holes they exposed.

The suite on #570 proves the exporter is **configured** right - the url,
the headers, the timeouts, all read off the constructor. Nothing looked
at the request that comes out of it, or at what happens when the
collector does not answer. Both gaps are invisible from inside Argent by
design: a failed export never reaches the caller, and an OTLP exporter
counts any 2xx as delivered.

| | |
|---|---|
| **`otel-wire.test.ts`** | Captures the real exporter's request off a
loopback socket: uncompressed OTLP/JSON on `POST /v1/logs`, where each
field the ingestion schema reads actually sits, the batch-size cap, and
connection reuse. |
| **`otel-unreachable.test.ts`** | Drives the same exporter at a silent
socket, a refused port, a 503, a 401 and a 204, and asserts each drain
finishes inside its deadline and what the collector really received. |
| **`src/otel.ts`** | Two holes the tests exposed: `compression` and the
six OTLP certificate variables are now settled in code instead of left
to the environment. |

**No docs update needed** - no user-facing capability, CLI surface,
config key, flow file or MCP tool changed.

<details>
<summary><b>The two source fixes</b></summary>

**`compression` was the one OTLP knob still left to the environment.**
With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` (or its `_LOGS_` variant) set
- and a machine already running OpenTelemetry has it set for its own
collector - argent gzips its batches. Nothing downstream notices: a
gzipped 2xx is a delivered batch, while the request this suite and the
deploy-time smoke test are written against quietly stops being the
request argent sends. `createExporter` now passes `compression`
explicitly, which beats both variables. Unlike the header channel, where
the SDK *merges* whatever the code does not set and the variables have
to be cleared outright, one explicit value settles this.

**The certificate variables could hang the command outright.** The SDK
resolves
`OTEL_EXPORTER_OTLP_{,LOGS_}{CERTIFICATE,CLIENT_CERTIFICATE,CLIENT_KEY}`
with a synchronous `fs.readFileSync` while the exporter is constructed,
and only then discards the https agent it built from them in favour of
the explicit `httpAgentOptions`. The file is read for nothing - and a
path that never answers hangs the command that emitted the event,
uninterruptibly, on `getClient()`'s path. Measured with a fifo that has
no writer: `createExporter` never returned (killed at 12s); with the
variables cleared, 1.0 ms. They join the header pair in the list cleared
across the constructor and restored on the way out.

Some source prose was also wrong about the SDK and is corrected: it does
**not** reject a null attribute - it serializes one as an empty OTLP
value, `{"key":"cloud_agent","value":{}}` with `droppedAttributesCount:
0`, stored as and unrecoverable from a property that really was empty.
That is the actual reason `toAttributes` drops the key.

</details>

<details>
<summary><b>What the tests pin, and what they deliberately do
not</b></summary>

The wire test **mirrors** `OtelClient.emit` rather than calling it:
`getClient()` resolves its endpoint from the hard-coded
`OTLP_LOGS_ENDPOINT`, and that being unredirectable is the
anti-exfiltration property the client is meant to have, so there is
deliberately no seam to aim it at a test server. `otel-endpoint.test.ts`
covers the other half against the real emit path - `SERVICE_NAME`, the
logger name, event-name-as-body, the dropping of null/undefined
properties - so a rename is caught there rather than in the mirror.

What enforces the drain budgets is the exporter's `timeoutMillis`, *not*
the processor's `exportTimeoutMillis`, which `forceFlush()` awaits
straight past. Measured against a silent socket: `exportTimeout` 200 /
`timeoutMillis` 5000 drains in 5012 ms, the reverse in 202 ms.

503 is the one collector state where the batch is sent twice, and the
request count is the only thing that tells a retried failure from a
rejected one - the SDK reports the class, not the code. The retry itself
is only reachable while the next backoff fits in what is left of
`timeoutMillis`, so the jitter is pinned to its low draw and the
assertion turns on the round trip alone.

`httpAgentOptions.timeout` is the one option with no behavioural
coverage: it covers a socket stuck in *connect*, which needs an address
whose packets are dropped and is not portably reproducible here. It
stays pinned at the constructor and the docstring says so. Its
`keepAlive` half **is** covered, on the two batches of a single drain.

Real deadlines cost real time: `otel-unreachable.test.ts` waits ~5.1 s,
taking the package suite from ~0.9 s to ~5.3 s. Inherent to driving real
deadlines against real sockets.

</details>

<details>
<summary><b>Verification</b></summary>

- `npm test -w @argent/telemetry` - **336 passed**, 19 files
- `typecheck:tests`, `eslint --max-warnings 0`, `prettier --check`,
`knip`, `tsc --build` all clean; CI green
- **Mutation-tested**, whole-suite counts rather than one test each:

  | mutation in `src/otel.ts` | tests red |
  |---|---|
  | `compression` pin dropped | 2 |
  | `compression` pinned to `"gzip"` | 5 |
  | any one of the 8 cleared env-var names dropped | 1 each |
  | env-clearing loop deleted | 2 |
  | the `finally` that restores it deleted | 1 |
  | `timeoutMillis` dropped | 5 |
  | `httpAgentOptions` dropped, or just its `timeout` | 1 |
  | `keepAlive` flipped to `false` | 2 |
  | `installDiagLogger`'s `!isDebugEnabled()` guard removed | 1 |
| `maxExportBatchSize` 20 → 19 or 25; `scheduledDelayMillis` changed | 2
|
  | `SERVICE_NAME` renamed | 2 |
  | `LOGGER_NAME` changed | 1 |
  | record body no longer the event name | 4 |
  | `severityText` INFO → WARN | 1 |
  | null/undefined property skip removed | 2 |
  | `url` given a suffix the config did not set | 9 |

- **Absence assertions carry positive controls.** The certificate test
reads its answer out of a `Failed to read ` prefix that lives in a
transitive dependency, and the 204 case asserts an empty diag channel;
both would pass just as well against a dead observer. Each now proves
its own observer live first.
- **Flake-tested.** The 503 case's exact two-request assertion used to
turn on the jitter draw: standing a 500 ms collector delay in for a
loaded runner made it fail 20/20 (13 on the request count, 7 on
`Timeout` masking the error class). With the jitter pinned, 20/20 pass.

</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved telemetry export reliability by preventing unintended
compression and certificate-related environment settings from affecting
exporter configuration.
- Ensured telemetry attributes with null or undefined values are omitted
rather than serialized as misleading empty values.
- Improved handling of unreachable or unresponsive telemetry collectors
with bounded failures and appropriate diagnostic reporting.
- Preserved connection reuse and batching behavior for more efficient
OTLP delivery.

- **Tests**
- Added comprehensive coverage for OTLP formatting, authorization,
batching, compression, certificates, connection handling, and failure
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:39:35 +02:00
Ignacy Łątka 82db9e2f9f test: stop tests leaving files in the shared system temp directory (#1057)
Three issues, one theme: tests and the shared system temp directory.

- **#858 / #854** were live leaks — three call sites captured a real
temp path and never removed it. One reaches the cleanup its file already
had; the other two get a cleanup the file lacked.
- **#836** turned out to be **already fixed on `main`** by #714, which
scoped those three tests' `TMPDIR` to a directory they own. This PR
verifies that rather than changing it — reproduction and mutation
evidence below.
- Sweeping the same shape across the two packages turned up **six more
test files** doing it, worth **44** leftover entries per run.
- A third shape, in **two** more files: `resolveFileInputs` hands its
caller a `cleanup` for whatever it materialized — the dispatcher's job
in production. Five call sites kept the result and never called it,
stranding 5 dirs per run.

A scoped-`TMPDIR` run of all 12 affected files leaves **one** entry
behind: an empty `argent-chromium-media/`, production's own fixed
staging dir. 173 tests, no other file or directory.

Fixes #858
Fixes #854
Closes #836

<details>
<summary>#858 — before/after temp scope</summary>

```
rm -rf /tmp/scope && mkdir -p /tmp/scope
TMPDIR=/tmp/scope TMP=/tmp/scope TEMP=/tmp/scope \
  npx vitest run --root packages/tool-server test/chromium-server-screenshot.test.ts
```

| site | before | after |
|---|---|---|
| `chromium-server-screenshot.test.ts` |
`argent-chromium-media/argent-screenshot-test-1788803062089-38318.png` |
*(no PNG)* |
| `chromium-cdp-blueprint.test.ts` |
`argent-chromium-media/argent-screenshot-chromium-cdp-52204-1788803091335-39401.png`
| *(no PNG)* |

Both files keep an empty `argent-chromium-media/` — that is production's
fixed staging dir (`src/chromium-server/cdp-session.ts:218`), shared
with any live chromium-server on the machine and not per-run garbage. A
test `rm`ing it would be the machine-global-resource defect #714 removed
elsewhere, so it is left alone deliberately.
</details>

<details>
<summary>#854 — before/after temp scope</summary>

```
TMPDIR=/tmp/scope … npx vitest run --root packages/argent-mcp test/content.test.ts
```

Before (`34 passed`, two directories left):
```
argent-mcp-content-kPfgYa/context.diff.png
argent-artifacts/test-temp-dir-hygiene-037c31/20260907-174453-39458/home-diff.png
```

After: **empty**. The `mkdtemp` moved onto a `beforeEach`/`afterEach`
pair mirroring the sibling describe, and `flowRunToMcpContent` now pins
`ARGENT_ARTIFACTS_DIR` the way the artifact describe above it already
did.

The unconditional `delete process.env.ARGENT_ARTIFACTS_DIR` is kept, not
converted to a save/restore: it is what keeps any future unpinned write
landing in `tmpdir` rather than a developer's configured artifacts
directory.

The pin is not hygiene-only — it is asserted. Removing
`process.env.ARGENT_ARTIFACTS_DIR = root` fails the test:
```
- diff: /tmp/scopeM4/content-flow-artifacts-LZKPAl
+   diff: /tmp/scopeM4/argent-artifacts/test-temp-dir-hygiene-037c31/20260908-100837-89701/home-diff.png
❯ test/content.test.ts:627  expect(artifactText?.text).toContain(`diff: ${root}`)
```
</details>

<details>
<summary>#836 — why no code changed, and the evidence</summary>

(This PR does change `file-inputs.test.ts`, but for the unrelated third
shape above — not for the sites #836 cites.)

The issue cites `readdir(os.tmpdir())` at `file-inputs.test.ts:180-197`
and `flow-visual.test.ts:586-602`. On current `main` neither exists:
#714 replaced both (and the `http-upload.test.ts:154-158` sibling the
issue also flags) with a per-test scratch directory the test creates,
points `os.tmpdir()` at via `redirectTmpdir`, and removes. There is now
**no** `readdir` of `os.tmpdir()` anywhere in either package.

The per-path assertion the issue asks for is not reachable at these
three sites — every name is a `mkdtemp` random suffix or a server-side
`randomUUID`, so the test cannot know the path. Scoping the tmpdir to a
directory the test owns is the same guarantee, and is what landed.

**Reproduced the reported failure** by reverting each site to the
global-listing shape, then running two simultaneous instances in a loop:

| file | pre-#714 shape | current |
|---|---|---|
| `file-inputs.test.ts` | **1 / 12** — `expected [
'argent-file-input-00mPU7', …(1371) ] to deeply equal [ …(1370) ]` | **0
/ 12** |
| `flows/flow-visual.test.ts` | **12 / 12** — `expected [
'argent-flow-crop-00R9XZ', …(4853) ] to deeply equal []` | **0 / 12** |
| `http-upload.test.ts` | — | **0 / 12** |

**Mutation-tested all three assertions** — each still catches a real
leak; production cleanup broken, test run, source restored:

| assertion | mutation | result |
|---|---|---|
| `file-inputs.test.ts:249` | drop `await cleanup()` from
`resolveFileInputs`' catch path | ✗ `+ [ "argent-file-input-amPJuK" ]` |
| `flow-visual.test.ts:610` | drop `cleanupDiffDir(cropDir, …)` from the
outer `finally` | ✗ `+ [ "argent-flow-crop-rWT9Bd" ]` |
| `http-upload.test.ts:187` | drop `rm(tarPath)` from the
`req.on("close")` handler | ✗ `+ [ "argent-upload-43d82021-….tar.gz" ]`
|
</details>

<details>
<summary>Sibling sweep — the same two shapes across both
packages</summary>

**Shape "assertion diffing a listing of `os.tmpdir()`": zero
remaining.** No `readdir`/`readdirSync`/`glob` over `os.tmpdir()`,
`tmpdir()` or a literal `/tmp` in either test tree. Every literal `/tmp`
string left is a fixture handed to a mocked API; the three that do touch
disk were checked by hand and are clean
(`native-devtools-socket-bind.test.ts` mkdtemps and removes,
`flow-utils.test.ts` is pure path math,
`native-profiler-external-device.test.ts` writes only inside its own
per-test `mkdtemp`).

**Shape "a captured temp path never cleaned up": six files fixed.** Each
gets one recording choke point plus an `afterEach`.

Running those six under a scoped `TMPDIR` (`48 passed` both times):

```
before: 44 entries      after: 1 entry
```

| file | sites |
|---|---|
| `screenshot-diff-tool.test.ts` | 9 |
| `react-profiler/ast-index.test.ts` | 6 |
| `event-log.test.ts` | 1 helper (4 dirs/run) |
| `screenshot-diff.test.ts` | 1 helper |
| `react-profiler/component-source-duplicates.test.ts` | 2 |
| `ios-instruments/combined-report-frozen-anchor.test.ts` | 1 |
| `flows/flow-visual.test.ts` | production's crop + diff dirs (5/run) |

**Left deliberately (1 entry):** an **empty** `argent-chromium-media/` —
production's fixed staging dir
(`src/chromium-server/cdp-session.ts:218`), shared with any live
chromium-server on the machine. A test `rm`ing it would be the
machine-global-resource defect #714 removed elsewhere.

Two further shapes turned up while checking that claim, both
*production* dirs a test drove and then abandoned:

| site | per run | why the test could not just `rm` it |
|---|---|---|
| `screenshot-diff-tool.test.ts` | 2 dirs + 2 PNGs | `resolveOutputDir`
mints `argent-screenshot-diff/<hex>` before anything validates the call,
so the path exists before the test can learn it — and on a throw it
never learns it at all |
| `flows/flow-visual.test.ts` | 5 dirs + 5 PNGs | `runSnapshot` leaves
the file it registered as an artifact inside its `argent-flow-crop-*` /
`argent-flow-diff-*` scratch dir; from there the dir belongs to whoever
consumes the artifact, which here is the test |

Both are fixed the same way, with `redirectTmpdir` (already used by
seven other test files): point `os.tmpdir()` at a directory the test
already owns and removes, so whatever production mints lands inside it
whether the call returns or throws. The `<hex>` and `mkdtemp` suffixes
are per-call and never shared, so nothing a concurrent tool-server owns
is touched — verified with a decoy file planted under the shared root,
which survives the run.

Measured under a scoped `TMPDIR`, three consecutive runs each:
`screenshot-diff-tool` 6 dirs + 6 PNGs → **0 + 0**; `flow-visual` 15 +
15 → **0 + 0**. The underlying production leak behind the first is still
#790.

`argent-mcp` is now clean end to end: every `mkdtemp` in the package has
a matching `rm`.
</details>

<details>
<summary>Checks</summary>

Run from the worktree root, exit codes read unpiped:

- `npm run format` → 0, no files rewritten
- `npx tsc --noEmit -p packages/tool-server/tsconfig.test.json` → 0
- `npx tsc --noEmit -p packages/argent-mcp/tsconfig.test.json` → 0
- `npm run lint` → 0
- `npm run knip` → 0
- no `it.only` / `describe.only` in the diff

Suites: `argent-mcp` **90 passed**; `tool-server` run as six shards
under a scoped `TMPDIR`.

`lens-tools-platform-gate.test.ts` times out at the 5 s default under
full-suite load and passes in isolation (`2 passed`, 3.14 s). It is not
in this diff and #714 recorded the same behaviour before it.
</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Tests**
- Improved test isolation by consistently cleaning up temporary files
and directories after each test.
- Added coverage verifying generated artifacts are stored under the
configured artifacts directory.
- Ensured screenshot, upload, event-log, profiling, visual-flow, and
diff-test artifacts are removed reliably after execution.
- Standardized temporary-directory handling and cleanup across test
scenarios to reduce leftover test data and improve repeatability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:39:02 +02:00
Ignacy Łątka 86a2668fb0 test: stop the suite and the process environment leaking into each other (#1058)
Test env leakage, both directions.

**Out** — six `argent-tools-client` suites pointed `HOME`/`USERPROFILE`
at a temp dir, deleted the dir, and never restored, so a `--no-isolate`
run handed every later file a home that no longer exists. Lifted the
redirect into a shared `test/helpers/home-redirect.ts`; the three suites
that already hand-rolled the save/restore moved onto it too.

**In** — `packages/telemetry` had no `setupFiles` at all, so its unit
tests read the developer's shell. Added
`test/setup/clear-telemetry-env.ts` (ARGENT_ prefix sweep +
`DO_NOT_TRACK` + the cloud-agent detector's markers), wired via
`setupFiles`, following the `@argent/tool-server` precedent — and
dropped the per-file guards it replaces.

**Pinned, not just fixed** — a `test/setup/assert-env-restored.ts` in
each of the three packages fails any suite that leaves a watched
variable changed, so dropping a restore can no longer pass. Each watches
`HOME` and `USERPROFILE`, plus `PATH` (`argent-tools-client`,
`tool-server`) or `CI` and the five `OTEL_EXPORTER_OTLP_*` (`telemetry`)
— the ones the sweep deliberately leaves ambient and individual tests
then set by hand. Each of the three `clear-*-env` setup files has a pin
test that re-imports it against planted sentinels;
`packages/tool-server`'s existing copy gains the mixed-case one it was
missing.

`packages/tool-server` is in the diff because it has the same two gaps:
it originated the prefix sweep (both pre-existing copies were
case-sensitive, so they are fixed together) and it has the largest
redirect surface of the three — 12 files hand-rolling the redirect plus
17 going through `scopeTempHome` — with no guard. It is already green
under one; deleting `scopeTempHome`'s restore turns 17 files red. Two of
its suites restored `HOME` by assigning back a saved value that can be
`undefined`, writing the string `"undefined"`; the guard caught both on
a host with no ambient `HOME`, and they are fixed here.

`windows-e2e.yml` runs seven `tool-server` files through the config this
branch edits, but neither that config nor `test/setup/**` was in its
`paths:` filter — so the new setup file would have reached
windows-latest unverified. Added both.

No docs change: test infrastructure only, no user-facing behaviour.

Fixes #1028
Fixes #839

Same-class problems found while reviewing this and filed rather than
folded in: #1068 (`argent-cli` fails under `DO_NOT_TRACK=1`), #1069 (the
`ios-device-runner` suites pin only `HOME`, so the redirect is inert on
Windows — its second half, the restore that wrote the string
`"undefined"`, is fixed here because the new guard caught it), #1070
(`useTempHome` shares one saved-`HOME` slot, so a nested `scopeHome()`
restores a deleted dir), #1071 (`tool-server`'s sweep is prefix-only, so
an exported `ADB_SERVER_SOCKET` reddens its suite), #1072
(`external-devices.test.ts` reads the developer's real
`~/.argent/providers`) and #1073 (`flow-script-environment.test.ts`
fails with no ambient `HOME`).

<details>
<summary>#1028 — probe results, six files</summary>

`test/zz-home-probe.test.ts` from the issue body, run per suite.
Vitest's sequencer orders by cached duration and falls back to file
size, so the cache is cleared before each run to keep the tiny probe
last:

```
rm -rf node_modules/.vite/vitest ../../node_modules/.vite/vitest
npx vitest run test/<suite>.test.ts test/zz-home-probe.test.ts --no-isolate --no-file-parallelism
```

| suite | before | after |
| --- | --- | --- |
| `launcher-state` | 1 failed \| 23 passed — `argent-state-test-…
exists=false` | 24 passed |
| `launcher-spawn` | 1 failed \| 8 passed — `argent-spawn-test-…
exists=false` | 9 passed |
| `launcher-version-gate` | 1 failed \| 7 passed —
`argent-version-gate-test-… exists=false` | 8 passed |
| `launcher-duplicate-spawn` | 1 failed \| 10 passed —
`argent-dup-test-… exists=false` | 11 passed |
| `launcher-exit` | 1 failed \| 1 passed — `argent-exit-test-…
exists=false` | 2 passed |
| `link-config` | 1 failed \| 43 passed — `argent-link-config-test-…
exists=false` | 44 passed |
| `launcher-sweep` (control, fixed in #713) | passed | passed |
| `launcher-ps-guard` (control, fixed in #713) | passed | passed |

The probe itself is a diagnostic and is not part of the diff —
`test/setup/assert-env-restored.ts` is the durable guard, and it needs
no `--no-isolate` to fire.
</details>

<details>
<summary>#839 — the stated repro is stale; here is what is actually
left</summary>

The issue was filed 2026-08-18. #714 landed 2026-08-19 and
hand-enumerated the variables inside `index.test.ts` (a
`CONSENT_ENV_KEYS` `beforeEach`) and `base-props.test.ts` (eight
`delete`s). On `aa90873b5` all three named repros are green:

```
$ DO_NOT_TRACK=1            npx vitest run --root packages/telemetry   → 319 passed
$ ARGENT_TELEMETRY=0        npx vitest run --root packages/telemetry   → 319 passed
$ CURSOR_AGENT_WORKER_ID=x  npx vitest run --root packages/telemetry   → 319 passed
```

What remains is the structural gap the issue names: the protection is
per-file and per-variable, nothing pins it, and `vitest.config.ts` still
has no `setupFiles`. This PR moves the property to the setup file and
deletes the two hand-rolled guards, so the setup file is what holds it —
see the mutation section, where removing the `setupFiles` line
reproduces the issue's 17 and 1 exactly.

Post-change sweep, one hostile value per run, 329 tests each:
`DO_NOT_TRACK=1`, `ARGENT_TELEMETRY=0`, `ARGENT_TELEMETRY_DEBUG=1`,
`CLAUDE_CODE_ENVIRONMENT_KIND=byoc`, `CLAUDE_CODE_ENTRYPOINT=remote`,
`CLAUDE_CODE_REMOTE_SESSION_ID=x`, `CURSOR_AGENT_WORKER_ID=x`,
`CURSOR_WORKER_POOL_NAME=x`, `GITHUB_ACTIONS=true`,
`GITHUB_ACTOR=copilot`,
`GITHUB_WORKFLOW_REF=…copilot-swe-agent.yml@refs/heads/main`,
`REPLIT_AGENT=1`, `ARGENT_HOST`/`ARGENT_PORT`/`ARGENT_AUTH_TOKEN`,
`CI=1`, `CI=false`, `OTEL_EXPORTER_OTLP_ENDPOINT`,
`OTEL_EXPORTER_OTLP_HEADERS`, the real CI combination, and all eleven at
once — 329 passed every time.

`GITHUB_ACTIONS` is a ci-info vendor input as well as a copilot-agent
marker, so clearing it does move `isCi()` — but only its GitHub Actions
branch, and an Actions run also sets `CI`, which `isCi()` answers ahead
of any vendor check. `GITHUB_ACTOR` and `GITHUB_WORKFLOW_REF` appear in
neither `ci-info/vendors.json` nor `ci-detect.ts`'s generic list, so
they cost nothing. The rest of the ci-info surface and `OTEL_*` are
deliberately left alone; the setup file says so.
</details>

<details>
<summary>Mutation testing — every guard, applied by hand, verified to
fail, reverted</summary>

| mutation | result |
| --- | --- |
| drop `setupFiles` from `packages/telemetry/vitest.config.ts` |
`DO_NOT_TRACK=1` → 19 failed (**17 in `index.test.ts`**, matching the
issue, + the two registration pins) |
| drop `CURSOR_WORKER_POOL_NAME` from `CLEARED_ENV_VARS` | drift test
red |
| move an `env.` read into `src/detect/vendor.ts` | drift test red,
naming `detect/vendor.ts` |
| rewrite a read as `env["X"]`, `const { X } = env`, `const { X }:
NodeJS.ProcessEnv = env` or `const { X = "d" } = env` | drift test red |
| add `cloud-agent-detect.ts` to `UNSWEPT_SRC_FILES` | drift test red |
| revert `toUpperCase()` in any of the three sweeps | that package's
mixed-case case red |
| drop `restoreHome()` from one suite / from all nine | 1 file / 9 files
red, each naming its deleted dir |
| drop `scopeTempHome()`'s restore in `tool-server` | 17 files red |
| drop the hand-rolled `CI` restore in `base-props.test.ts` | red,
naming that file (green before; under `--no-isolate`, 7 unrelated
failures in `otel-endpoint.test.ts`) |
| narrow either guard's filter to variables that were already set |
`assert-env-restored.test.ts` red |
| replace the leaked half of the guard's message with a constant |
`assert-env-restored.test.ts` red |
| rename one function's `env` parameter in `cloud-agent-detect.ts` |
drift test red — the read is found under the new binding |
| narrow any guard's filter to a subset of `SCOPED_ENV_VARS` |
`assert-env-restored.test.ts` red — the pin plants every entry |
| drop the hand-rolled OTLP restore in `otel-endpoint-live.test.ts` |
red, naming all four leaked variables |
| move the guard's `AMBIENT` snapshot inside `assertEnvRestored` |
`assert-env-restored.test.ts` red in all three packages |
| delete the guard's `afterAll(...)` registration |
`assert-env-restored.test.ts` red in all three packages |
| drop `restoreHome()` from `artifacts.test.ts`'s two `afterEach` | red
— the file-level guard catches a per-test omission |
| drop ps-guard's `PATH` restore | red, naming the file |
| empty `leakedEnvVars`, or drop a name from `SCOPED_ENV_VARS` |
`assert-env-restored.test.ts` red |
| replace the helper's delete branch with a plain assignment |
`home-redirect.test.ts` red |
| drop `USERPROFILE` from the helper | `home-redirect.test.ts` red |

</details>

<details>
<summary>Local CI parity</summary>

```
npx vitest run --root packages/argent-tools-client               213 passed | 1 skipped
npx vitest run --root packages/argent-tools-client --no-isolate  213 passed | 1 skipped
npx vitest run --root packages/telemetry                         329 passed
npx vitest run --root packages/telemetry --no-isolate            329 passed
npx vitest run --root packages/tool-server                       6166 passed | 1 skipped
npx tsc --noEmit -p packages/{argent-tools-client,telemetry,tool-server}/tsconfig.test.json   clean
npm run format / npm run lint / npm run knip                                                 clean
```
</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Tests**
- Improved test isolation by consistently restoring temporary
environment settings after test runs.
- Added safeguards that detect leaked environment settings and flag
incomplete cleanup.
- Added coverage to ensure telemetry-related environment variables are
cleared before tests and do not affect results.
- Added cross-platform checks for environment variable handling,
including mixed-case names.
- Expanded validation for home-directory redirection and restoration
behavior.
- Added checks confirming environment cleanup runs in the correct order
across test suites.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:38:51 +02:00
Ignacy Łątka 5bc9c7be4c test: make four non-discriminating test sites fail on what they claim to pin (#1059)
Four tests could not fail on the thing they name: an assertion implied
by the one below
it, an assertion satisfied by the fixture's own filename, a clearance
poll with nothing
to clear, and `it.each` rows whose rendered name described only argv[0]
- two of them
stating the opposite of what the row asserts.

Each site now fails when its claim is broken, proven by mutation. Tests
only, no source
change, so no docs update.

Fixes #943
Fixes #942
Fixes #841
Fixes #861

<details>
<summary>#943 &mdash; capability: toolId/platform were substrings of the
path assertion</summary>

`toContain("demo-tool")` + `toContain("android")` are both substrings of
`tools/demo-tool/platforms/android.ts`, asserted on the next line.
Replaced by the prose
sentence, which names both outside the path.

Mutation - drop the sentence naming the tool and the platform:

```diff
-      `Tool '${opts.toolId}' is not yet implemented on ${opts.platform}. ` +
+      `Tool is not yet implemented. ` +
```

| | result |
|---|---|
| before | `Tests 7 passed (7)` - green |
| after | `AssertionError: expected 'Tool is not yet implemented. The
cros…' to contain 'Tool \'demo-tool\' is not yet impleme…'` |

The sibling "works without a hint" assertions are load-bearing and
untouched.
</details>

<details>
<summary>#942 &mdash; native-profiler: the wording assertion matched
inside missing_cpu.xml</summary>

`/missing|not found|unreadable/i` matched the fixture filename, so any
product copy
passed. The wording now has to land before the backticked path (a
negated character class
cannot cross into it), which no path can satisfy.

Mutation - reword the ENOENT copy:

```diff
-    if (code === "ENOENT") return `not found at \`${filePath}\``;
+    if (code === "ENOENT") return `absent at \`${filePath}\``;
```

| | result |
|---|---|
| before | `Tests 1 passed (1)` - green (also green with the state word
deleted outright) |
| after | `AssertionError: expected '# iOS Instruments Analysis…' to
match /-\s*\*\*cpu\*\*:[^…]*(?:missing\|not found\|unreadable)/i` |
</details>

<details>
<summary>#841 &mdash; vega-cli: the output-cap reaps had nothing to
observe</summary>

`expect(await waitForClear(SENTINEL_OVERFLOW)).toBe(0)` reads 0 just as
readily for a
launcher that never spawned a worker. The cap trips on the first write,
so there was no
window to see one in: the fake now spawns its sentinel worker **before**
the flood and
holds the flood - and so the reap - until the test writes a gate file,
having seen the
worker. Both the stdout and the stderr twin.

Gated rather than timed, so the observation is decided, not raced. Cost
is ~+110ms and
~+50ms on the two tests (165/177ms to 278/226ms).

Mutation - the flood branches' workers stop carrying the sentinel, reap
path untouched:

```diff
-  const worker = spawn("sleep", [secs], { stdio: "ignore" });
+  const worker = spawn("sleep", ["600.0"], { stdio: "ignore" });
   ...
-  spawnSync("sleep", [secs]);
+  spawnSync("sleep", ["600.0"]);
```

| | result |
|---|---|
| before | `Tests 12 passed (12)` - green, and `pgrep -f '^sleep
600\.0$'` empty (the reap works; nothing observed it) |
| after | both overflow tests `AssertionError: expected +0 to be 1` |

Cross-check that the pair pins the production reap: with `reapOnce()`
made a no-op in
`vega-cli.ts`, both fail `expected 2 to be +0`.

**Flake runs: 30 consecutive passes** (2 x 15, before and after the
comment pass),
`12 passed (12)` every time.
</details>

<details>
<summary>#861 &mdash; four it.each rows named only argv[0] (plus a fifth
site)</summary>

A single `%j` against a multi-element row renders only the first
element. Each row is now
one argv, nested, so `%j` names all of it.

`packages/argent-cli/test/tools-help.test.ts`

```
- prints usage for "--help" without contacting the tool-server   <- duplicate
- prints usage for "--json" without contacting the tool-server   <- false: a bare --json
                                                                    prints no usage and
                                                                    does hit the server
+ prints usage for ["--help","--json"] without contacting the tool-server
+ prints usage for ["--json","--help"] without contacting the tool-server
```

`packages/argent/test/installer-help.test.ts`

```
- treats "--foo" as a help request      <- rendered twice; a bare --foo is the opposite
+ treats ["--foo","--help"] as a help request
+ treats ["--foo","help"] as a help request
- does not treat "--metro-port" as a help request
+ does not treat ["--metro-port","8082"] as a help request
```

Swept the class across every `it.each` array-literal table in the repo
(154 sites). One
more reproduces it -
`packages/argent-cli/test/telemetry-command.test.ts`, fixed here:

```
- prints usage for status and writes nothing    <- rendered twice
- prints usage for disable and writes nothing   <- false: a bare `disable` writes config
+ prints usage for ["status","--help"] and writes nothing
+ prints usage for ["status","-h"] and writes nothing
+ prints usage for ["disable","-h"] and writes nothing
```

Left alone: ~64 sites with a discriminating label column (`_label`
first, house style) and
16 whose first column is a real discriminator - names unique and
truthful in both groups.
`providers-command.test.ts:440` drops a third "shape" column from its
name but stays
unique and true, so it is a legibility nit, not this defect.
</details>

<details>
<summary>Checks run</summary>

- `npm run format`, `npm run lint` (the latter needs `npm ci` inside
`packages/docs` locally)
- `npx tsc --noEmit -p
packages/{tool-server,argent-cli,argent}/tsconfig.test.json`
- `packages/tool-server`: `npx vitest run --shard=1/6` .. `6/6` - 6163
passed, 1 skipped
- `npx vitest run --root packages/argent-cli` (619), `--root
packages/argent` (80)
- `vega-cli-timeout.test.ts` x30 consecutive, zero flakes
</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved test parameterization so command-line help cases display
complete argument lists accurately.
* Strengthened error-message and profiler warning assertions to verify
exact wording and placement.
* Improved subprocess timeout and output-limit tests by synchronizing
worker startup and cleanup checks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:38:37 +02:00
Ignacy Łątka c3bb2990ea test(flows): stop pinning idle and type-focus waits to exact wall-clock boundaries (#1066)
Real-timer assertions in the flows suite were pinned to exact wall-clock
boundaries, so a batch run reddened them for reasons unrelated to
whatever was
being tested. Each now pins the same property with slack, or with no
clock at
all — every one was mutation-tested to confirm it still fails loudly
when the
behaviour it guards is removed.

Test-only; no docs change.

Fixes #1000
Fixes #795

<details>
<summary>Per-assertion: what it pins, and what changed</summary>

| Site | Pinned property | Before | After |
| --- | --- | --- | --- |
| `flow-type-focus` "skips the focus poll…" | the fixed 500ms settle
still runs on a source that cannot report focus | `gap >= 500` | `gap >=
450` |
| `flow-type-focus` "waits for the tapped field…" | settle **plus** at
least one 300ms focus poll | `gap >= 800` | `gap >= 720` |
| `flow-idle-run` "holds a still screen…" | `stableFor` is served, not
ignored | run timed, `[750, 2400)` | span between first and last tree
read, `>= 750` |
| `flow-idle-run` "settles inside the smallest timeout…" | the hold is
counted across the polls, so the parser's floor is servable | run timed,
`[750, 1000)` | read span `>= 750`; "inside the wait" carried by
`warning === undefined` |
| `flow-idle-run` "does not call a slow but answering source a wedged
one" | an abandoned read is not a wedge when the source's own slowest
answer explains it | 2300ms reads / 4600ms wait, 5s default test timeout
| 2800ms / 5400ms, own 20s timeout |
| `flow-idle-run` "does not report a tree-only settle…" | the tree-only
settle owes the same hold | churn window clocked from before the run |
clocked from the first read |
| `flow-idle-run` H1/H2 "settled early and then moved / went blank" |
the tree-only verdict is not a write-once latch | 4 quiet reads in a
2500ms wait | 3 quiet reads in a 6000ms wait, reads asserted |

The two focus bounds had zero slack in the one direction that matters: a
500ms
`setTimeout` measured on `Date.now()` spans 499ms roughly once in 200
runs
(400 samples, idle machine: `499ms x2, 500ms x48, 501ms x289, 502ms x55,
503ms x2, 505ms x1`). 10% of slack keeps them failing by hundreds of
milliseconds if either wait is dropped.

The two idle `elapsed` bounds timed the whole run, so the flow's
start-up —
parse, device resolve — counted against the step's own budget. Timing
between
the reads the hold is actually measured across removes that, and the
upper
bounds went with it: every way out of the wait except the settle carries
a
warning, so `warning === undefined` already says it landed inside.

The remaining three are fixtures, not assertions: each assumed a number
of poll
rounds, or a wall-clock window opened before the run, that a batch run
does not
serve. They now count reads, or anchor the window on the first read.

</details>

<details>
<summary>Repro — before</summary>

`npx vitest run packages/tool-server/test/flows` on `origin/main`:

```
 FAIL  flow-idle-run.test.ts > settles inside the smallest timeout the parser allows for the hold
AssertionError: expected 2439 to be less than 1000

 FAIL  flow-idle-run.test.ts > does not call a slow but answering source a wedged one
Error: Test timed out in 5000ms.

 FAIL  flow-idle-run.test.ts > does not report a tree-only settle when the hold was never served
AssertionError: expected 'settled on the UI tree alone — this s…' not to contain 'UI tree alone'

 FAIL  flow-idle-run.test.ts > does not pass on a screen that settled early and then started moving again
AssertionError: expected 'settled on the UI tree alone — this s…' to contain 'never held still'
```

#1000's own intermittency did not surface in 10 rounds of
`npx vitest run --shard=3/3` (4 idle, 6 under a 10-way CPU load), so it
was
forced instead: the `setTimeout`-vs-`Date.now()` measurement above is
the whole
mechanism, and the assertion had no margin over it.

</details>

<details>
<summary>Repro — after</summary>

Six batch rounds of `npx vitest run packages/tool-server/test/flows`, on
a
machine carrying other work (load average 22-30 throughout). Rounds 2-6
were
additionally narrowed to `--maxWorkers=3`, which is harsher than CI: the
13
`flow-composition` cases of #1067 pin one of the three workers for ~13
minutes
and starve the rest.

| round | workers | result for the assertions this PR touches |
| --- | --- | --- |
| 1 | default | all clean |
| 2 | 3 | H1 starved to 3 reads |
| 3 | 3 | all clean |
| 4 | 3 | all clean |
| 5 | 3 | all clean |
| 6 | 3 | H2 starved to 3 reads |

Every other failure in those rounds was the same 13 `flow-composition`
timeouts (#1067), plus load artifacts in `flow-selector-scopes`,
`flow-tool-step-tree-target-e2e`, `flow-launch-pins-tree-target` and
`flow-gesture-settle` — none of whose assertions this branch touches.

The two starved rounds were a real gap, not noise: H1 and H2 each need a
fourth read to reach a verdict at all, and
a 3500ms wait serves eighteen on an idle machine but three on a starved
one.
The case then saw a screen that never moved and passed the wait cleanly,
so the
assertion ran against `undefined`:

```
AssertionError: the given combination of arguments (undefined and string) is
invalid for this assertion.
```

Each case now budgets for the starved run and asserts the reads it was
served,
so an environment that cannot reach the verdict says so:

```
AssertionError: reads served — under four, nothing moved to be judged:
expected 3 to be greater than or equal to 4
```

Confirmed by stretching each read to 1800ms, which starves the wait to
three
reads without touching the wait itself.

</details>

<details>
<summary>Mutation tests (source broken, assertion must fail)</summary>

Each mutation was applied to `flow-actions.ts`, the test run, then
reverted.

| Mutation | Assertion under test | Result |
| --- | --- | --- |
| delete `sleepOrAbort(TYPE_FOCUS_SETTLE_MS, …)` | both focus gaps |
FAIL — `expected 0 to be greater than or equal to 450`; `expected 303 to
be greater than or equal to 720` |
| focus poll sleeps `0` instead of `POLL_INTERVAL_MS` | `>= 720` | FAIL
— `expected 504 to be greater than or equal to 720` |
| drop `&& heldForMs >= stableFor` from the settle | both idle read
spans | FAIL — `expected 403 to be greater than or equal to 750` (both)
|
| drop `slowestAnsweredReadMs +` from the hung check | "slow but
answering" | FAIL — step errors, `expected false to be true` on `r.ok` |
| drop `&& now - treeSince >= stableFor` from `treeSettledAtLastRead` |
"tree-only settle" | FAIL — `expected 'settled on the UI tree alone…'
not to contain 'UI tree alone'` |
| `if (lastRead === "timeout")` without `&& treeReadHung` | "still
warns, rather than erroring" | FAIL — `expected 'error' to be 'pass'` |
| make `treeSettledAtLastRead` a write-once latch | H1 "settled early
then moved again" | FAIL — reports the tree-only settle |
| drop `treeSettledAtLastRead = false` from the blank-read branch | H2
"settled early then went blank" | FAIL — reports the tree-only settle |

The `treeReadHung` one did **not** fail at `timeout: 3000`: with the
round
period dividing the wait exactly, drift decided whether the closing read
was
abandoned at all. `3200` leaves the seventh read 200ms — less than the
source
needs — so the case ends on an abandoned read by arithmetic.

</details>

<details>
<summary>Why the latch waits stop at 6000ms, and H3 is
untouched</summary>

The first attempt at these two used a 12000ms wait, which passed CI and
broke
the budget-tail case below them in the same file — that one has to fit
three
reads into a 2000ms window, and with 24s of polling ahead of it it saw
two:

```
expected 'the screen came back with content on 2 reads in 2000ms…'
  to contain 'never held still'
```

Deterministic, not load: 4/4 runs on the branch, 0/4 on `origin/main`,
and it
passes in isolation. Bisected across the file:

| latch wait | whole file |
| --- | --- |
| 2500 (main) | 46 passed |
| 3500 | 46 passed |
| 6000 | 46 passed |
| 12000 | budget-tail fails every run |

So the latch cases take 6000ms and no more. H3 itself is left exactly as
it is
on `main`: widening its wait scores its abandoned closing read as a
wedged
source (`HUNG_TREE_READ_MS`) rather than a step that ran out of time,
and
slowing its source starves it below the three reads a verdict takes —
both
were tried against CI and both moved the failure rather than removing
it.

</details>

<details>
<summary>Deliberately not changed</summary>

- `flow-idle-run`'s `elapsed < 3000` bounds at "honours its timeout",
"rides
  over a dropped capture" and "stops on abort": 1.8–2.2s of slack over a
sub-second expectation already clears a scheduling delay of hundreds of
  milliseconds.
- `flow-composition.test.ts` times out 13 cases at 60s each in the same
batch
run, on `origin/main` as well as here, while passing in 27s on its own.
It is
  a batch artifact of a different kind and belongs in its own issue, not
  papered over from here.

</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Improved idle-flow timing validation by measuring intervals between
reads for more reliable settle detection.
* Updated timing scenarios and thresholds to account for delayed reads,
startup overhead, slow tree changes, and early-settle behavior.
* Added checks to ensure sufficient reads occur before slow-flow
warnings and to detect later movement or blanking.
* Relaxed Android and iOS focus-wait timing thresholds to accommodate
minor timer measurement jitter.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:36:01 +02:00
Arkadiusz b6576ef60c optimization: move some instructions out of the argent-device-interact skill into reference files (#968)
# Why

Skills are getting bloated affecting benchmark results.

# What

- added `<SKILL>/references/chromium.md` — no point of loading that
information to the context when argent is used with iOS or Android
- added `<SKILL>/references/secrets.md` — typing secrets is rather
unusual.
- reduced duplicated information for await-ui-element by redirecting to
the tool's description

# Note

Not tested. Relying on CI.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Documentation**
- Clarified supported device-interaction targets, including that TV
targets are not supported.
- Narrowed Chromium support to Electron apps and Chromium-based browsers
with a CDP endpoint.
  - Added guidance for Chromium tabs, cookies, and web storage.
- Documented secret placeholder resolution, safeguards, and submission
requirements.
- Updated UI-wait guidance for hidden elements, root containers, roles,
and timeout notes.
  - Clarified platform-specific interaction behavior and tool usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-08 14:00:32 +02:00
filip131311 aa90873b59 feat(android): read the web DOM inside a WebView in describe (#1052)
Android `describe` rendered an `android.webkit.WebView` as a single
opaque leaf labelled `[web-view]` and threw the subtree away. Chromium
publishes the web DOM to the accessibility tree on both capture paths
(the android-devtools helper and the `uiautomator dump` fallback), so
the sentinel hid content the agent can act on. This keeps the WebView as
a landmark and lets the normal trim run over the DOM beneath it.

Supersedes #955, which reached the same diagnosis. The parser rules for
the doubled in-app node, the web list clip and the text-run role are
carried over from it; the rendered-output cap, the `truncated` flag
plumbing and the flow-tree parity rework are not, so this stays confined
to the Android parser and adapter (about 120 source lines) with no
renderer, contract, skill or tool-description changes.

### What changes inside a WebView, and only there

- **Doubled node merged.** An in-app WebView dumps as a nested pair: the
app's view (resource-id, gesture flags), then Chromium's root web area
under the same class carrying the page `<title>` as text. The inner half
folds into the outer node, so the tree reads `WebView "<title>"`. A
Chrome tab arrives as one node and is untouched.
- **Web list clip.** A `<ul>` arrives as `android.widget.ListView` with
`scrollable="false"`. Inside a WebView only the framework flag decides
what clips, so a list never drops the items laid out around it, and the
role is `List` rather than `ScrollView`.
- **Text runs.** A labelled leaf `android.view.View` with no gesture
flag is reported as `StaticText`. Outside a WebView a bare `View` keeps
its role (Compose semantics nodes).
- **Headings survive.** The WebView branch returns before the label
dedupe, so an `h1` that repeats the page title is kept.

### The cold-read race is fixed, not documented

Chromium builds a WebView's accessibility tree on the first request for
it. Measured on API 35 / WebView 124: a read taken 6 s after page load
still shows the WebView with no children; the page appears only after a
read has asked for it — 10–400 ms later for a small in-app page, ~600 ms
later for a Chrome tab over a long article. `describeAndroid` re-reads
in 250 ms steps within a 1.5 s budget while the tree still has the
unread shape, on both paths. A screen without a WebView never pays for
it (one call, pinned by test). A WebView that stays empty after the
budget is labelled `(no web content exposed)`.

The unread shape is an `android.webkit.WebView` with no children, or —
for a browser tab, which has no WebView view of its own before the page
is published — Chrome's childless content view labelled "Web View"
(English UI only; other locales see the tab the way they do today).

### Measured on a Pixel 9 / API 35 emulator with the branch tool-server

| Screen | Before | After |
|---|---|---|
| In-app checkout form, cold launch | `WebView "[web-view] (no label)"`
| full form in one call, 1.2 KB, 0.6 s |
| 120-paragraph page | sentinel | the ten visible paragraphs, 1.8 KB
(existing scroll clip bounds it) |
| Wikipedia article in Chrome, helper attached after the page loaded |
`FrameLayout "Web View"` | 4.7 KB, heading, links, lists, 1.4 s |
| Settings home (native) | — | byte-identical to the previous parser |

### Testing

- New `android-webview-describe.test.ts` on three live captures checked
in as fixtures (in-app, Chrome, cold): merged landmark, `List` role,
heading kept, re-read fires on the cold in-app shape and on Chrome's
content-view placeholder, never on a native screen, and gives up within
the budget.
- `uiautomator-parser-v2-trim.test.ts`: the opaque-leaf case is replaced
by the merge, the empty-WebView leaf, and a native `View` that must not
be remapped.
- Full tool-server suite, `tsc`, eslint, prettier, docs build.

### Docs

No docs, skill or tool-description change. The page already names the
accessibility tree as the source on Android; WebView content is now part
of that tree and the agent needs no new instruction, since the cold read
no longer produces an empty WebView.

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

https://claude.ai/code/session_01GZbkzeVC9faSxTFi4DbMuT


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Android WebView content is now captured and presented with meaningful
controls, text, lists, and links.
* Web content is consolidated into a clearer, unified accessibility
view.
* **Bug Fixes**
* Improved Android screen descriptions when WebView content is initially
unavailable by retrying the accessibility hierarchy.
* Added safeguards for WebViews that never expose content, including a
clear fallback message.
* **Tests**
* Expanded coverage for in-app WebViews, Chrome tabs, cold captures, and
native Android UI filtering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <hubert.gancarczyk@swmansion.com>
2026-09-07 16:35:42 +02:00
Hubert Gancarczyk 236db0077f fix(tool-server): support ios-remote in the await tools (#1051)
## Problem

The `await-ui-element` and `await-screen-idle` tools refused a remote
simulator. The tool server sent this error:

```
Tool 'await-ui-element' is not supported on ios-remote simulator (no ios-remote support declared).
```

Both tools poll the same accessibility tree as `describe`. The
`describe` tool reads that tree correctly on a remote simulator.
Therefore the tree source was never the problem.

## Cause

Each tool declares its platform support in a capability record. The
`describe` tool declares `appleRemote`. The two wait tools did not
declare it. The HTTP layer rejects the request before the tool starts.

Three more branches test `platform === "ios"`. That test is false for a
remote device. These branches select the tree source, the dependency
preflight, and the tvOS probe.

Nobody excluded the platform deliberately. Git history has no commit
that adds `appleRemote` to either file. The `appleRemote` concept
arrived on 2026-07-01. The `await-screen-idle` tool arrived two days
later.

## Effect on an agent session

The MCP layer calls `await-screen-idle` before each automatic
screenshot. On a remote simulator that call failed. The fallback then
slept for the full budget:

- 1500 ms for each tap or gesture
- 2000 ms for `open-url`
- 3000 ms for `launch-app` and `restart-app`
- 15000 ms for `run-sequence`

The user saw no error. The fallback exists for an old tool server, not
for a current server that refuses a device.

## Change

This change adds three lines to each tool:

1. The capability record declares `appleRemote: { simulator: true }`.
2. The tree fetch sends a remote device to `describeIos`. Before, a
remote device fell through to the Chromium branch.
3. The dependency preflight requires `sim-remote`, and not `xcrun`.

The `describe` tool uses the same three settings. The ax-service
blueprint sends the accessibility read through the sim-remote tunnel.

## Test result

I ran the branch tool server against a leased Argent Cloud runner. The
device was a remote iPhone 17 Pro on iOS 26.5. The app was Bluesky. The
app read its bundle from a local Metro server through the sim-remote
tunnel.

| ID | Case | Result |
| --- | --- | --- |
| T1 | `visible` by text | `success: true` |
| T2 | `exists` by identifier | `success: true` |
| T3 | `exists` by a second identifier | `success: true` |
| T4 | `text` condition matches the label | `success: true` |
| T5 | compound role and text selector | `success: true` |
| T6 | absent text gives `unmet` | `success: false` |
| T7 | wrong expected text gives `unmet` | note quotes the actual text |
| T8 | 5000 ms budget honoured | `elapsed: 5003` |
| T9 | `hidden` on an absent element | `success: true` |
| T10 | screen settles | `settled: true, polls: 2` |
| T11 | screen honours `minStableMs: 2000` | `waitedMs: 2265, polls: 8`
|
| T12 | wait across an app restart | `success: true, elapsed: 27301` |
| T13 | `hidden` after the restart | `success: true` |
| T14 | flow record boundary | UNKNOWN verdict |

All 14 cases passed. Two results give the strongest evidence:

- Case T12 blocked for 27.3 seconds across an app restart. The app
rebuilt its bundle, the element appeared, and the tool returned success.
- Case T6 returned `cause: "unmet"`. A failed tree read returns `cause:
"unreadable"`. Therefore the tool read the remote tree and judged the
condition.

### Unit tests

Five new unit tests drive both tools with a remote udid. They use a mock
accessibility service. Each test fails against `main` and passes with
this change.

Local checks:

- `npm run build`: clean
- `npm test -w @argent/tool-server`: 431 files, 6153 tests, 0 failures
- `npm run lint`: clean
- `npx prettier --check`: clean

## Note on flow records

The flow tools declare no capability. Therefore they accept a remote
device today. This change lets the re-probe reach `fetchTree`, which has
no support for `ios-remote`. The recorder catches that error. The
recorder then keeps the step and marks the verdict UNKNOWN, not
known-bad.

An earlier test asserted the refusal. This change inverts that test and
records the new behavior in its comment.

## Documentation

This change needs no documentation update. The tool reference lists
these two tools with a description only. That page has no platform
column, and no page describes remote simulators.

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-07 14:42:23 +02:00
Hubert Gancarczyk 0f90d182b2 feat(flows): record a script step by running it (#940)
#864 (the executor) and #865 (the `script:` step) are merged. This
branch now
sits directly on `main` as a single commit, so the diff and the commit
list are
this change alone.

A `script:` step could only enter a flow by being typed into the YAML
after the
recording ended. The authoring skill said so in as many words, and
listed it
among the legal unrecorded insertions. That made it the one directive
exempt
from every rule the recorder exists to enforce, and it cost two things:

- **The script never ran while the flow was authored.** It first
executed during
the closing replay, where a failure reads as a flow that was never right
rather than a script that needs one more attempt. Worse, the device
steps
recorded after it were walked against backend state the author had
established
by hand — so the recording proved nothing about whether the script
establishes
  that same state.
- **The author never saw what the script returned.** A later release
reads
`{{output:user.id}}`; the only way to know that path exists is to have
seen the
  document.

This adds one MCP tool, `flow-add-script`, that runs a local `.mjs` and
records
the step it ran. **No new flow syntax** — the `script:` step is
unchanged, and
this is a second way for one to enter a flow file.

```text
flow-add-script { name: "checkout", project_root: "/Users/dev/shop",
                  path: "../../scripts/seed-order.mjs", timeout: 60000 }
```

## What it adds

- **One shared execution path.** Path resolution, the on-disk casing
guard, the
executor call and the verdict were private to `flow-run.ts`. They are
now
`runFlowScriptStep` in `flow-script-step.ts`, with the file-ref helpers
in
`flow-file-refs.ts`, and `runScriptStep` is that call plus its
`ExecState`
plumbing. This is the load-bearing change: "the recording runs the
script
exactly as replay will" is one function, not two code paths that agree
today.
- **A failure records nothing.** The opposite of `flow-add-step`,
deliberately.
A failed script did not establish the state the rest of the walkthrough
would
then be recorded against, so an appended red step costs every step after
it,
not just itself. The `message` also says whether a child actually *ran*
— a
path that resolved to no file forked nothing, while a script that threw
halfway did not roll back — because the author's next move is a retry or
a
  cleanup and they cannot pick without knowing.
- **The returned document, as JSON text.** Text rather than an object
because
this is the first tool result carrying bytes the server did not author:
every
  result is deep-walked for `__argentClientFile` and `__argentArtifact`
directives, which match on shape alone and write or fetch files on the
agent's
  machine. As a string it inherits none of that. Bounded at 64 KiB with
  `outputTruncated`, for the reason `log` is bounded.
- **`{{output:` refused by `parseFlow`.** Surfacing the document invites
an
author to write a reference that has no meaning yet — it would be
printed
literally by a step that passes, green and wrong. The scan runs over
exactly
the field list the output release declares supported, so it is a seam
that
release converts from reject to resolve rather than scaffolding it
deletes.
Patterns are excluded: a `{{` in a regular expression is a quantifier
the
  author meant.
- **Two boundaries.** A `client`-mode recording is refused — its `.mjs`
never
left the agent's machine, the same anchor reason `run:`, `snapshot` and
  uploaded flows are refused. And the tool is `longRunning`, so the MCP
adapter's 30s fetch abort cannot retry a call whose whole purpose is a
side
effect; the comment records what that flag does *not* close, and where
the
  rest belongs.
- **`flow-add-step` says when the call already ran.** The `{{output:`
refusal is
the one refusal this release adds on a path where the tool call has
already
fired, and its message asks for an edit-and-retry — which repeats the
device
  action. Verified: a `keyboard` step recorded twice left
`{{output:code}}{{output:code}}` in the field. That one refusal now
carries
  what already happened.

## Documentation

Rule 5 of `argent-create-flow` no longer lists `script:` as a legal
unrecorded
insertion — leaving it there would tell agents to keep doing the thing
this PR
removes. `live-authoring.md` gains the recorder contract, the return
fields that
are not self-evident, and the ordering rule: a setup script is recorded
*before*
the `restart-app` it prepares state for, because that is where it runs
at
replay. A leading script does not make the flow a fragment. `qa-flows`
and
`reliability-and-recovery` follow the count and the wording.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check`
over
tracked files, and `npm run typecheck:tests -w @argent/tool-server` are
green.
`npm test -w @argent/tool-server`: 358 files, 4657 passed, 1 skipped.

Exercised end to end against a live tool server built from this branch,
not
only in tests — recorded a flow, then replayed the saved YAML through
`argent flow run` with no hand editing:

```
flow-add-script → status "pass", durationMs 124
  log: "seeding order for the flow\nwarn: using the fake backend\n"
  outputJson: {"order":{"id":"ord_4417","total":39.5},
               "user":{"id":"usr_9","email":"buyer@example.com"}}
  recorded: 1. script: ../../scripts/seed-order.mjs (timeout 30000ms)
```

```yaml
steps:
  - script:
      path: ../../scripts/seed-order.mjs
      timeout: 30000
  - echo: order seeded; expect Home
```

```
Flow "checkout"
  ✓  1 script ../../scripts/seed-order.mjs
       │ seeding order for the flow
       │ warn: using the fake backend
  › order seeded; expect Home

PASS — 1 passed, 0 failed, 0 errored, 0 skipped
```

The three refusals, live on the same recording:

- A script that threw after creating records: `status "fail"`,
`stepCount`
unchanged at 1, and the message naming the side effects that were not
rolled
  back.
- A path resolving to no file: refused before any fork, no `durationMs`,
and
  "Nothing ran, so there is nothing to clean up".
- `flow-add-echo` holding `{{output:user.id}}`: refused with the
author-facing
  message naming the step, the field and the later release.

No device is involved at any point — a recording may open with a seeding
script
before it boots anything.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the `flow-add-script` tool to run local `.mjs` scripts and
record successful executions in flows.
* Added script status, output, duration, logs, and failure details to
recording results.
* Added clearer handling for script cancellation, timeouts, missing
files, and path casing.

* **Documentation**
* Updated flow guidance to clarify end-to-end classification and script
setup or cleanup usage.
* Documented script path requirements, failure conditions, and recording
workflows.

* **Bug Fixes**
* Flows now reject unsupported output-reference templates with more
precise diagnostics.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 12:59:29 +02:00
Hubert Gancarczyk 9c4bc9ef5b feat(flows): run an external script as a flow step (#865)
Stacked on #864, which adds the executor. This branch adds the `script:`
step
itself: the YAML directive, the runner integration, the CLI and MCP
rendering,
and the authoring reference.

```yaml
- script: { path: ../../scripts/seed-order.mjs }
- script: { path: ../../scripts/seed-order.mjs, timeout: 60000 }
```

A `script:` step runs an external `.mjs` file from the project, in a
fresh Node
process. What the script does is its own business — seeding an order,
creating
a test account, cleaning up what a run left behind are the reasons flows
have
wanted one. It drives no device, so a flow of nothing but scripts runs
with
nothing booted.

## What it adds

- **The directive.** The value is always a map; `path` is required and
obeys
the same name rules a `run:` target does, spelled for `.mjs`. It
resolves
against the directory of the flow file containing the step, so a
fragment
reaches the same script whichever flow composed it. A mis-cased path is
refused rather than run, because macOS and Windows would open it and
Linux CI
  would not.
- **The report.** The script's stdout and stderr come back on the step
and
print under the step line, on a pass as well as a failure — it is the
only
record of what the step did to the backend. Both renderers (CLI, MCP)
carry
  it, live and buffered.
- **The fail/error split.** A `fail` is the script's own answer; an
`error` is
the host's. That is what lets CI tell a regression from the machine it
ran
  on.
- **Two boundaries.** An uploaded flow carrying a `script:` step is
refused
before anything runs — its `.mjs` never left the client. A flow whose
script
  step sits beside a `run:` step still resolves a device.

## Review follow-ups

The last three commits answer a review of the three above them.

- A launch behind a leading `script:` was not read as the flow's leading
launch, so a Chromium flow that seeded a backend first hoisted no boot,
bound
  to whatever browser happened to be up, and passed a launch it never
  performed. The same lead-in walked a fragment past the
`executionPrerequisite` refusal its `echo:`-led twin is given. Both
readers
  now share one exhaustively-switched predicate.
- Coverage: the whole failure-kind table, the mis-cased arm CI never
ran, the
two unreached `scriptFileProblem` arms, symlink resolution for a script
path,
  and the CLI live-progress closure that no test had executed.
- Docs: the reference claimed the log was secret-redacted (it is not),
never
mentioned the environment allowlist, the working directory, the
time-limit
ceiling or the log caps, stated a fail/error rule that was not the
code's,
and was missing from its own table of contents. The skills stated a
closed
list of legal unrecorded insertions that excluded the only way this step
can
  ever be written.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check
.`,
`npm run knip`, `npm run typecheck:scripts` and `npm run test:scripts`
are
green, as are the three `tsconfig.test.json` typechecks. Suites:
tool-server
357 files / 4552 passed, `@argent/cli` 469, `@argent/mcp` 83,
`@argent/registry` 90.

Reproduced end to end against a real tool server and a real Electron
app, not
only in tests: a flow of `script:` then `launch:` now boots its own
Chromium
instance and reports it, where before it attached to a stray browser and
reported a green launch for an app path that did not exist.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for local `.mjs` scripts as flow steps, including
configurable timeouts, validation, cancellation, and device-free
execution.
- Script output is displayed in CLI runs and included in MCP flow
results, with indentation and truncation notices.
  - Script failures now provide clearer diagnostics and verdicts.
  - Flow authoring tools recognize and guide creation of script steps.

- **Documentation**
- Added guidance for script syntax, execution limits, output handling,
classification, and remote-run restrictions.
  - Updated flow creation and QA guidance to include script steps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-09-07 12:08:13 +02:00
filip131311 c573e9c524 feat(telemetry): report device_kind next to platform on tool events (#1049)
## Why

Telemetry cannot tell a physical iPhone from a simulator: `platform` is
`ios` for both, and the tool-server's `kind` never reaches the events.
Physical-iOS support shipped in 0.24.0 with no way to count its adoption
except through `ios_device_*` failure stages on `tool:fail`. Physical
Android phones have the same gap.

## What

`tool:invoke` / `tool:complete` / `tool:fail` gain `device_kind:
simulator | emulator | vvd | device | app`, derived from the same device
id as `platform` (single classifier in `telemetry-platform.ts`, which
`debugger:tool_outcome` and `lens:*` also go through for their
platform). `platform: ios` + `device_kind: device` is a physical iPhone.

The kind is emitted only for positively recognised id shapes, so the
metric is not polluted by ids that are not devices:

| id shape | platform | device_kind |
|---|---|---|
| simulator UUID / `remote:` | ios / ios-remote (tvos when the cache is
warm) | simulator |
| physical iPhone UDID | ios | device |
| `emulator-NNNN` | android (android-tv when warm) | emulator |
| USB serial, 6–20 alnum with a digit and a letter | android | device |
| non-loopback `ip:port` | android | device (best effort) |
| loopback `127.0.0.1:port` / `localhost:port` / `::1:port` | android |
emulator |
| anything else non-iOS-shaped (Metro 40-hex handle, `booted`,
`Pixel_7`, IPv6, mDNS) | android | omitted |
| `chromium-cdp-<port>` / `amazon-…` | chromium / vega | app / vvd |
| `avdName`-only boot | android | omitted |

A child sub-tool that names its own device replaces the parent's
platform, kind and provider label wholesale, so an `avdName`-only child
never reports the parent's `simulator` next to its own `android`. A
logical-keyed Metro session strips the kind.

`unknown` (in the registry `DeviceKind`) is rejected by the sanitizer
like `platform: "unknown"`; no registry change.

## Querying

- Physical iPhone adoption: `tool:invoke` where `platform = 'ios' and
device_kind = 'device'`, distinct users over it. `isIosPhysicalUdid` is
a positive match, so invoke is clean.
- Physical Android: count on `tool:complete` (the device answered).
`tool:invoke` / `tool:fail` still carry residual mistakes that happen to
look like a serial (`Pixel7`), and a non-loopback `ip:port` may be a
networked virtual device.

## Docs

- `Telemetry.md` → 1.04, effective date 7 September 2026 (adjust to the
release date on merge). Discloses the new category and, while touching
that section, the external-provider label that #735 added without a
notice line — happy to drop that second bullet if it should ship
separately.
- No docs-site change: `docs/reference/telemetry.mdx` links to
`Telemetry.md` and has no per-property list.

## Notes

- Pre-existing, unchanged: HTTP-edge failures carry no
`tool_invocation_id`; `stop-all-simulator-servers` declares no
capability, so its invoke row has neither field while its fail row has
both. `device_kind` follows `platform` in each site.
- Legacy 40-hex iPhone UDIDs already classify as `android` today; they
now get no kind rather than a wrong one.
- Design went through three adversarial review rounds before
implementation and one on the diff; the fallback-bucket and
child-attribution rules above came out of those.

## Verification

- `packages/telemetry`: 319 tests, `packages/tool-server`: full suite
5670 passed / 1 skipped; `typecheck:tests` both packages; `eslint
--max-warnings 0`; `knip`; prettier.
- New: `test/telemetry-device-kind.test.ts` (the shape table above),
plus cases in `http-tools-meta`, `http-platform-alias`, `sanitize`,
`registry-listener`.

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

https://claude.ai/code/session_01BSGpeAwvoCv2JAhbLsuto2


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Telemetry now records a device category—such as simulator, emulator,
physical device, virtual device, or desktop app—alongside the platform.
* Device categories are inferred from device information and may include
a short label for external device providers.
* Device category information is included in tool invocation,
completion, and failure telemetry when available.
* Device identifiers themselves continue to be excluded from telemetry.
* **Documentation**
* Updated the Argent Privacy Notice (Telemetry) to version 1.04,
effective 7 September 2026.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 11:55:57 +02:00