Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
16dd5eb
fix(tv): element tree after tv-remote, and working auto-describe on A…
latekvo Oct 5, 2026
798c773
docs(skills): TV loop reads focus from the tree tv-remote returns
latekvo Oct 5, 2026
03a628f
chore: tighten uiautomator lock comment
latekvo Oct 5, 2026
1200c3a
docs(tv): point TV tool descriptions and skill at the returned focus …
latekvo Oct 5, 2026
32c5905
fix(describe): log the helper failure before the Android TV dump fall…
latekvo Oct 5, 2026
7e9523c
test(mcp): assert the tv-remote delay from the map, not the env-depen…
latekvo Oct 5, 2026
e114998
fix(tv): point at describe when the returned focus view is missing; p…
latekvo Oct 5, 2026
00672ef
fix(tv): correct TV describe prose, skill and delay docs; pin the emp…
latekvo Oct 7, 2026
1118b17
fix(tv): Android TV focus view drops off-screen nodes, marks one focu…
latekvo Oct 7, 2026
cfe2854
fix(tv): reap the in-simulator Apple TV AX reader on recycle and disp…
latekvo Oct 7, 2026
733c631
fix(vega): serialize toolkit page-source reads per device
latekvo Oct 7, 2026
38723ce
fix(tv): give the Apple TV HID socket a per-server path; pin the reap…
latekvo Oct 7, 2026
015b62c
docs(config): name the actor in the auto-screenshot delay row
latekvo Oct 7, 2026
64ffac0
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
0141592
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
6f7d446
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 9, 2026
fb3eb17
Merge branch 'main' into fix/tv-auto-describe
latekvo Oct 14, 2026
4fd1357
Merge remote-tracking branch 'origin/main' into fix/tv-auto-describe
latekvo Oct 25, 2026
5a9c0ac
fix(await-screen-idle): count focus and selection in the settle signa…
latekvo Oct 25, 2026
2b7e9cd
test(await-screen-idle): pin the focused marker; docs: the settle wai…
latekvo Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/argent-mcp/src/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const AUTO_SCREENSHOT_TOOLS = new Set([
"paste",
"rotate",
"fold",
"tv-remote",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every Apple TV screenshot is written to $TMPDIR/argent-tv-screenshot-<udid8>-<hrtime>.png (tools/screenshot/index.ts:139-142), and nothing in the repo deletes it. With tv-remote captured, every remote press on Apple TV now leaves one more file; they were 10-600 KB each at the default scale in my run. Before this PR, only explicit screenshots, launch-app and keyboard wrote them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Confirmed, and it is older than this PR: every backend that writes its own PNG into os.tmpdir() has it. On this machine $TMPDIR holds 380 vega-screenshot-* and 57 argent-tv-screenshot-* files, and ios-remote writes argent-remote-screenshots/<uuid>.png the same way. Nothing unlinks a registered artifact. The registry store is an in-memory map with no eviction. With tv-remote captured, the per-press rate goes up, so I filed it as #1277 rather than patching only the Apple TV path here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Still present as of 5a9c0ac. Each tv-remote press on Apple TV or Vega writes one PNG under os.tmpdir() that is never removed. Tracked in #1277, which is still open.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A secret typed into a plain text field on a TV now shows up in the next tv-remote result. The capture of the keyboard {text: "{{secret:X}}"} call itself is skipped. On a TV, though, the next call is almost always a tv-remote to reach the submit button, with the field still on screen. That call is now captured: the field is in the screenshot, and on Android TV its text is in the focus view. The skip note does say the next call is captured, and password fields are masked, so this hits non-password fields typed outside a run-sequence. Before this PR, tv-remote was not captured at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Not changing this. The capture after a secret is the same on every platform: on a phone the next gesture-tap to the submit button is captured in the same way. The skip note on the keyboard call says so explicitly ("the next call is captured normally, and shows the secret if the field is still on screen") and gives the fix: put the typing and the submit in one run-sequence. run-sequence accepts tv-remote steps, so that remedy works on a TV as well.

"launch-app",
"restart-app",
"open-url",
Expand All @@ -44,6 +45,7 @@ export const AUTO_SCREENSHOT_DELAY_MS_BY_TOOL: Record<string, number> = {
"gesture-rotate": 1500,
"run-sequence": 15000,
"button": 1500,
"tv-remote": 1500,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: On an Apple TV simulator, every MCP tv-remote call now waits the full 1500 ms before capturing. await-screen-idle there polls describeIos(..., {isTvOs: true}), which always returns emptyTree(), and an empty tree never counts as settled. A scratch run of await-screen-idle with isTvOsSimulator mocked to true returned {settled:false, waitedMs:1503, polls:9}. The PR body acknowledges this and points at #681; noting it here because the delay map's JSDoc ("usually returns well under" the cap) is never true for this entry on tvOS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Leaving this to #681, which gives Apple TV a real wait primitive. Until then the cost is latency only (1.5 s per press), and the screenshot and focus view are still correct.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Still present as of 5a9c0ac. A scratch await-screen-idle with isTvOsSimulator mocked true returned {settled:false, waitedMs:1502, polls:9} for the 1500 ms tv-remote cap. Tracked in #681, which is still open.

"rotate": 1000,
// The fold tool already waits for the panel hand-over; the cap covers the
// repaint after it.
Expand Down Expand Up @@ -80,6 +82,7 @@ export const AUTO_DESCRIBE_TOOLS = new Set([
"paste",
"rotate",
"fold",
"tv-remote",
Comment thread
latekvo marked this conversation as resolved.
"launch-app",
"restart-app",
"open-url",
Expand Down
8 changes: 8 additions & 0 deletions packages/argent-mcp/test/auto-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ describe("autoDescribeEnabled", () => {
});
});

describe("auto-capture after tv-remote", () => {
it("captures the screen and the focus tree after a TV remote press", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This checks that tv-remote is in the two sets, which the loops further down already cover, and restates the 1500 constant. Nothing tests what the PR is about: that a tv-remote call through mcp-server.ts comes back with the screenshot and the --- Elements after action (describe) --- section.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Not adding an mcp-server.ts harness in this PR. The test is not redundant with the loops: they iterate the sets, so removing tv-remote from them leaves them green. I removed it from AUTO_DESCRIBE_TOOLS, and only this test failed (1/39). For tv-remote, mcp-server.ts reads only those two sets and the delay map, and that code path is shared by all 17 captured tools. mcp-server.test.ts has no call-level harness for any of them. The end-to-end check (screenshot plus --- Elements after action (describe) --- after tv-remote on all three TV platforms) was run through the real argent-mcp over stdio and is in the PR description.

expect(shouldAutoScreenshot("mcp__argent__tv-remote")).toBe(true);
expect(shouldAutoDescribe("mcp__argent__tv-remote")).toBe(true);
expect(AUTO_SCREENSHOT_DELAY_MS_BY_TOOL["tv-remote"]).toBe(1500);
});
});

describe("shouldAutoDescribe", () => {
it("is true for every listed tool, with or without a client prefix", () => {
for (const tool of AUTO_DESCRIBE_TOOLS) {
Expand Down
8 changes: 4 additions & 4 deletions packages/skills/skills/argent-tv-interact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ description: Control and inspect TV apps via argent — Apple TV (tvOS), Android
## Critical

- A TV is **focus-driven, not touch-driven.** Drive every interaction with `describe` + `tv-remote` + `keyboard`; never use `gesture-*` / coordinate taps — they don't apply on any TV platform.
- **Always `describe` before navigating** to find the live cursor and your target — never guess focus from a screenshot. The cursor is the focused element; on **Vega** the toolkit often leaves `focused` false and marks the highlighted item `[selected]`, so treat `[selected]` as the cursor when nothing reports `[focused]`.
- **Always read the focus view before navigating** (`describe`, or the tree the last action returned) to find the live cursor and your target — never guess focus from a screenshot. The cursor is the focused element; on **Vega** the toolkit often leaves `focused` false and marks the highlighted item `[selected]`, so treat `[selected]` as the cursor when nothing reports `[focused]`.
- Pass the `udid` from `list-devices` — an Apple TV simulator UDID or an Android TV / Vega `serial`. Dispatch is automatic from the id; the same tools drive all three.

## The navigation loop

1. `describe` — find the cursor and your target (returns the focused element + all focusable ones, not a tap tree).
2. `tv-remote` — move focus toward the target. Prefer **one** call with a path ending in `select`, e.g. `{button:["down","right","select"]}`; count rows/columns from the frames to build the path.
3. `describe` again to confirm. On a miss, repeat.
2. `tv-remote` — move focus toward the target. Prefer **one** call with a path ending in `select`, e.g. `{button:["down","right","select"]}`; count rows/columns from the list order (and, on Vega, the frames) to build the path.
Comment thread
latekvo marked this conversation as resolved.
Outdated
3. Read where focus landed from the focus view returned under `--- Elements after action (describe) ---`; call `describe` only when that section is missing. On a miss, repeat.

## Tools

- `describe {udid}` — focus view: the focused / `[selected]` element + focusable elements with labels and normalized frames. Call before and after navigating. Empty tree → see the per-platform notes.
- `describe {udid}` — focus view: the focused / `[selected]` element + focusable elements with labels (plus normalized frames on Vega). Call it before the first press on a screen; `tv-remote`, `keyboard` and `launch-app` return it after the action. Empty tree → see the per-platform notes.
Comment thread
latekvo marked this conversation as resolved.
Outdated
- `tv-remote {udid, button}` — D-pad / remote. `button` is one key **or a whole path** (run in one call). Keys: `up`/`down`/`left`/`right`, `select`, `back`, `menu`, `home`, `playPause`, plus media keys `rewind`/`fastForward`/`next`/`previous`/`volumeUp`/`volumeDown`/`mute`. Single: `{button:"down"}`; repeat: `{button:"down", repeat:3}`; path: `{button:["up","right","select"]}`.
- `keyboard {udid, text}` — type into the focused field (focus it with `tv-remote` first). One call carries `text` or `key`, never both — to type and then press a key, send two `keyboard` steps in one `run-sequence`. Named `key` presses (e.g. `{key:"enter"}`) work on Vega; on Apple TV / Android TV move focus with `tv-remote` instead.
- `launch-app` / `restart-app` / `reinstall-app {udid, bundleId}` — `bundleId` from the app manifest. Vega `reinstall-app` takes `appPath` = a `.vpkg`.
Expand Down
34 changes: 19 additions & 15 deletions packages/tool-server/src/blueprints/android-tv-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { adbExecOutBinary, adbShell, getAndroidRuntimeKind } from "../utils/adb";
import { assertTypeableAndroidText, injectAndroidText } from "../utils/android-input";
import { UnsupportedOperationError } from "../utils/capability";
import { withUiautomatorLock } from "../utils/uiautomator-lock";
import {
parseUiAutomatorXml,
attrIsTrue,
Expand Down Expand Up @@ -135,6 +136,17 @@ function toTvElement(n: TvNode): TvElement {
};
}

/** Focus view of a uiautomator-schema hierarchy (a `uiautomator dump` or the android-devtools helper). */
export function tvFocusViewFromXml(xml: string): TvDescribeResponse {
const { focused, focusable } = collectTvNodes(xml);
Comment thread
latekvo marked this conversation as resolved.
Outdated
const pkg = focused?.pkg || focusable.find((n) => n.pkg)?.pkg;
return {
bundleId: pkg || undefined,
focused: focused ? toTvElement(focused) : null,
focusable: focusable.map(toTvElement),
};
}

export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInfo> = {
namespace: ANDROID_TV_CONTROL_NAMESPACE,

Expand Down Expand Up @@ -187,10 +199,12 @@ export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInf
const suffix = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
const dumpPath = `/data/local/tmp/argent-tv-dump-${suffix}.xml`;
const raw = (
await adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
await withUiautomatorLock(serial, () =>
adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
)
)
).toString("utf-8");
if (!raw.includes("<hierarchy")) {
Expand All @@ -202,23 +216,13 @@ export const androidTvControlBlueprint: ServiceBlueprint<TvControlApi, DeviceInf
return raw;
}

async function read(): Promise<{ focused: TvNode | null; focusable: TvNode[] }> {
return collectTvNodes(await dumpHierarchy());
}

async function pressKey(direction: TvDirection): Promise<void> {
await adbShell(serial, `input keyevent ${KEYEVENTS[direction]}`, { timeoutMs: 10_000 });
}

const api: TvControlApi = {
async describe(): Promise<TvDescribeResponse> {
const { focused, focusable } = await read();
const pkg = focused?.pkg || focusable.find((n) => n.pkg)?.pkg;
return {
bundleId: pkg || undefined,
focused: focused ? toTvElement(focused) : null,
focusable: focusable.map(toTvElement),
};
return tvFocusViewFromXml(await dumpHierarchy());
},

async navigate(direction: TvDirection): Promise<void> {
Expand Down
10 changes: 9 additions & 1 deletion packages/tool-server/src/tools/await-screen-idle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { describeIos, iosRequires } from "../describe/platforms/ios";
import { describeIosDevice } from "../describe/platforms/ios-device";
import { describeAndroid, androidRequires } from "../describe/platforms/android";
import { describeChromium } from "../describe/platforms/chromium";
import { describeVega, vegaRequires } from "../describe/platforms/vega";

const AWAIT_SCREEN_IDLE_TOOL_ID = "await-screen-idle";

Expand All @@ -30,7 +31,9 @@ const zodSchema = z.object({
udid: z
.string()
.min(1)
.describe("Target device id from `list-devices` (iOS UDID, Android serial, or Chromium id)."),
.describe(
"Target device id from `list-devices` (iOS UDID, Android serial, Vega serial, or Chromium id)."
),
timeoutMs: z
.number()
.int()
Expand Down Expand Up @@ -72,6 +75,7 @@ const capability: ToolCapability = {
appleRemote: { simulator: true },
android: { emulator: true, device: true, unknown: true },
chromium: { app: true },
vega: { vvd: true },
Comment thread
latekvo marked this conversation as resolved.
};

// Frames are normalized 0..1, so rounding to 0.01 tolerates sub-pixel jitter
Expand Down Expand Up @@ -112,6 +116,9 @@ export function createAwaitScreenIdleTool(registry: Registry): ToolDefinition<Pa
if (device.platform === "android") {
return describeAndroid(registry, device.id, undefined, androidIsTv);
}
if (device.platform === "vega") {
Comment thread
latekvo marked this conversation as resolved.
return describeVega(device.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return describeChromium(services.chromium as ChromiumCdpApi);
}

Expand Down Expand Up @@ -148,6 +155,7 @@ still before the timeout. Use after a launch/navigation to wait for the UI to re
if (device.platform === "ios") await ensureDeps(iosRequires);
else if (device.platform === "ios-remote") await ensureDeps(["sim-remote"]);
else if (device.platform === "android") await ensureDeps(androidRequires);
else if (device.platform === "vega") await ensureDeps(vegaRequires);
Comment thread
latekvo marked this conversation as resolved.

// Resolve tvOS / Android-TV once. Physical devices skip the tvOS probe. They are never tvOS simulators.
const isTvOs =
Expand Down
4 changes: 3 additions & 1 deletion packages/tool-server/src/tools/await-ui-element/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ const zodSchema = z
udid: z
.string()
.min(1)
.describe("Target device id from `list-devices` (iOS UDID, Android serial, or Chromium id)."),
.describe(
"Target device id from `list-devices` (iOS UDID, Android serial, Vega serial, or Chromium id)."
),
condition: z
.enum(["exists", "visible", "hidden", "text"])
.describe(
Expand Down
2 changes: 1 addition & 1 deletion packages/tool-server/src/tools/describe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ For React Native apps, debugger-component-tree returns React component names wit
On a TV target (Apple TV / Android TV — a \`list-devices\` entry with runtimeKind 'tv') this returns
the focus-driven view instead: the currently FOCUSED element and the list of FOCUSABLE elements,
since a TV UI has no tap coordinates. Move the highlight with \`tv-remote\` (up/down/left/right/select/
back/menu/home), then call describe again to confirm where focus landed.`,
back/menu/home); over MCP its result carries the focus view after the press, elsewhere call describe again.`,
alwaysLoad: true,
searchHint:
"accessibility element tree ui hierarchy tap coordinates ios android chromium vega dom tv tvos apple tv android tv fire tv focus focusable remote dpad",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FAILURE_CODES, FailureError } from "@argent/registry";
import type { Registry, ToolDependency } from "@argent/registry";
import type { DescribeNode, DescribeTreeData } from "../../contract";
import { adbExecOutBinary, isAndroidTv } from "../../../../utils/adb";
import { withUiautomatorLock } from "../../../../utils/uiautomator-lock";
import { resolveDevice } from "../../../../utils/device-info";
import {
getAndroidScreenSize,
Expand Down Expand Up @@ -108,10 +109,12 @@ async function uiautomatorDump(serial: string): Promise<string> {
// wrappers, RN SVG sub-paths, bounds-less Compose containers) while keeping the
// text, content-desc, clickable and resource-id the agent contract uses.
// `;` rather than `&&` before `rm -f` so cleanup fires even when dump/cat fails.
const rawBuf = await adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
const rawBuf = await withUiautomatorLock(serial, () =>
adbExecOutBinary(
serial,
`uiautomator dump --compressed ${dumpPath} >/dev/null && cat ${dumpPath}; rm -f ${dumpPath}`,
{ timeoutMs: 20_000 }
)
);
const raw = rawBuf.toString("utf-8");
const trimmed = raw.trim();
Expand Down
35 changes: 31 additions & 4 deletions packages/tool-server/src/tools/describe/platforms/tv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { DescribeResult } from "../contract";
import { formatDescribeTree } from "../format-tree";
import { resolveTvApi } from "../../tv/tv-service";
import { describeAndroid } from "./android";
import { tvFocusViewFromXml } from "../../../blueprints/android-tv-control";
import { androidDevtoolsRef, type AndroidDevtoolsApi } from "../../../blueprints/android-devtools";
import type {
TvControlApi,
TvDescribeResponse,
Expand All @@ -27,7 +29,7 @@ const EMPTY_HINT =
"exposes focus once its JS bundle has loaded. If it stays empty, take a screenshot to confirm " +
"what's actually on screen.";

// Android TV reads focus from the OS accessibility tree (uiautomator), which
// Android TV reads focus from the OS accessibility tree, which
// does not expose focus driven by react-native-tvos's own focus engine — so the
// focus view can be empty on a screen that visibly has selectable tiles.
const ANDROID_FOCUS_EMPTY_HINT =
Expand All @@ -37,6 +39,28 @@ const ANDROID_FOCUS_EMPTY_HINT =
"these screens even though the labels aren't enumerable, so you can drive blind + screenshot " +
"to confirm.";

// A running android-devtools helper holds the device's only UiAutomation
// connection, so a `uiautomator dump` beside it dies `Killed`. Read through the
// helper; the dump is the fallback when the helper read fails.
async function readAndroidTvFocus(
registry: Registry,
device: DeviceInfo,
api: TvControlApi
): Promise<TvDescribeResponse> {
try {
const ref = androidDevtoolsRef(device);
const devtools = await registry.resolveService<AndroidDevtoolsApi>(ref.urn, ref.options);
return tvFocusViewFromXml((await devtools.getHierarchy()).xml);
Comment thread
latekvo marked this conversation as resolved.
Outdated
} catch (err) {
console.debug(
`[describe.tv] devtools helper failed, falling back to uiautomator dump: ${
err instanceof Error ? err.message : String(err)
}`
);
return api.describe();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

function isEmpty(res: TvDescribeResponse): boolean {
return res.focusable.length === 0 && !res.focused;
}
Expand Down Expand Up @@ -91,9 +115,12 @@ export async function describeTv(registry: Registry, device: DeviceInfo): Promis

// Ride out a brief post-launch transition window (see EMPTY_RETRY_*). Apple TV
// only: on Android TV an empty focus set is steady state for react-native-tvos
// screens, not a transition, so retrying would just burn uiautomator dumps
// screens, not a transition, so retrying would just burn reads
// before the empty-focus fallback below.
let res = await api.describe();
let res =
device.platform === "android"
? await readAndroidTvFocus(registry, device, api)
Comment thread
latekvo marked this conversation as resolved.
Outdated
Comment thread
latekvo marked this conversation as resolved.
Outdated
: await api.describe();
if (device.platform !== "android") {
for (let attempt = 1; attempt < EMPTY_RETRY_ATTEMPTS && isEmpty(res); attempt++) {
await sleep(EMPTY_RETRY_DELAY_MS);
Expand All @@ -111,7 +138,7 @@ export async function describeTv(registry: Registry, device: DeviceInfo): Promis
}

// Android TV with a still-empty focus engine: fall back to the full
// uiautomator tree so describe stays useful on RN-focus-engine screens.
// UI tree so describe stays useful on RN-focus-engine screens.
if (isEmpty(res) && device.platform === "android") {
// The dispatcher routed us here via isAndroidTv, so pass isTv through to
// skip a redundant probe. Let a capture failure propagate: describeAndroid
Expand Down
2 changes: 1 addition & 1 deletion packages/tool-server/src/tools/tv-remote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function createTvRemoteTool(registry: Registry): ToolDefinition<Params, T
A TV is navigated with a directional remote, not touch — use this instead of gesture-tap/swipe (which do not apply on a TV). Move focus with up/down/left/right, confirm with select, go back with back/menu, exit with home, and use playPause/rewind/fastForward/next/previous/volumeUp/volumeDown/mute for the corresponding remote keys. (On the Apple TV simulator the media-transport and volume keys are rejected — its HID stack ignores them; they work on Android TV and Vega.)
Single press: { button: "down" }. Repeat the same button: { button: "down", repeat: 3 }.
Multi-step navigation: pass a path as { button: ["up","right","right","select"] } — it runs in one tool call, far cheaper than separate presses.
Read the screen with \`describe\` before and after to see where focus landed.
Call \`describe\` before the first press to find the cursor. Over MCP the result carries the focus view after the press; otherwise call \`describe\` again to see where focus landed.
Comment thread
latekvo marked this conversation as resolved.
Outdated
Returns { pressed, count }.`,
alwaysLoad: true,
// A path (≤64 buttons) × repeat (≤50) flattens to thousands of presses, sent
Expand Down
9 changes: 9 additions & 0 deletions packages/tool-server/src/utils/uiautomator-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { withKeyedLock } from "./keyed-lock";

// Keyed by adb serial. Overlapping `uiautomator dump`s on one device get one of
// them `Killed`, and a timed-out wait tool leaves its dump running.
const uiautomatorDumpLocks = new Map<string, Promise<unknown>>();

export function withUiautomatorLock<T>(serial: string, fn: () => Promise<T>): Promise<T> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing cancels a queued dump. A wait that gives up still has its dump run later, and every later read on that device queues behind it.

  • pollDescribeTree stops waiting for a fetch at its deadline without cancelling it.
  • withKeyedLock always runs fn once the earlier holder settles.

This only matters when the helper is unavailable and reads fall back to the dump, but then it adds up. Several back-to-back await-ui-element or await-screen-idle calls with short timeouts each leave a dump queued, and the next describe waits for all of them. Each dump takes about 2.5 s and can run up to the 20 s adb timeout.

A unit-level run with five 50 ms waits over 200 ms dumps made the following describeAndroid take 944 ms, about 4.7 dumps. Before the lock, overlapping dumps failed with Killed; now they become latency for whatever runs next.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Not changing this. The trade-off is intended. Without the lock, the dump a timed-out wait leaves behind overlaps the next read, and one of them dies Killed (3/3 with two overlapping adb exec-out uiautomator dump). With the lock, that case costs latency instead of a failed read. It only applies when the helper is unavailable. In the MCP path after a tv-remote, at most one capped await-screen-idle dump is in flight before the auto-describe, so the cost is one dump. The pile-up in your unit run needs several back-to-back short waits issued by the caller. Cancelling a queued dump would need an abort signal plumbed through pollDescribeTree and describeAndroid, which is a separate change.

return withKeyedLock(uiautomatorDumpLocks, serial, fn);
}
48 changes: 48 additions & 0 deletions packages/tool-server/test/await-screen-idle-vega.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";

const fetchVegaPageSource = vi.fn();
vi.mock("../src/utils/vega-inspect", () => ({
fetchVegaPageSource: (...a: unknown[]) => fetchVegaPageSource(...a),
}));

import { createAwaitScreenIdleTool } from "../src/tools/await-screen-idle";
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";

const PAGE_SOURCE = readFileSync(join(__dirname, "fixtures", "vega-page-source.xml"), "utf8");
const VEGA_SERIAL = "amazon-4e311aa3932a35be";

describe("await-screen-idle on Vega", () => {
beforeEach(() => {
fetchVegaPageSource.mockReset();
__resetDepCacheForTests();
__primeDepCacheForTests(["adb"]);
});

it("settles by polling the Vega automation toolkit", async () => {
fetchVegaPageSource.mockResolvedValue(PAGE_SOURCE);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The mock returns the identical page source on every poll, so this proves routing, the capability and the adb preflight, not settling. No case covers:

  • a tree that changes between polls;
  • the launcher-only page source a restarted app shows before it attaches;
  • MultipleVegaDevicesError.

The "toolkit unreachable" case would also pass if describeVega threw, since pollDescribeTree swallows both.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Diplomat, Opus 5.5]: Partly fixed in 00672ef. A new case alternates two page sources (one label changed) and expects settled:false after more than 2 polls, so settling now depends on the tree actually holding still. I left MultipleVegaDevicesError out: pollDescribeTree swallows every fetch error into settled:false, the same as on the other platforms, and the describe that follows rethrows it to the caller. A describeVega that throws instead of returning an empty tree is pinned by the describe Vega tests, not this file.

const tool = createAwaitScreenIdleTool({} as any);

const result = await tool.execute(
{},
{ udid: VEGA_SERIAL, timeoutMs: 2000, pollIntervalMs: 10, minStableMs: 20 }
);

expect(result.settled).toBe(true);
expect(result.polls).toBeGreaterThan(1);
expect(fetchVegaPageSource).toHaveBeenCalledTimes(result.polls);
});

it("does not settle while the toolkit is unreachable", async () => {
fetchVegaPageSource.mockRejectedValue(new Error("ECONNREFUSED"));
const tool = createAwaitScreenIdleTool({} as any);

const result = await tool.execute(
{},
{ udid: VEGA_SERIAL, timeoutMs: 60, pollIntervalMs: 10, minStableMs: 20 }
);

expect(result.settled).toBe(false);
});
});
Loading
Loading