Skip to content

Commit 1f042c3

Browse files
refactor(mcp): extract reference-pin state into tool-ref-pins module (#1344) (#1345)
* refactor(mcp): extract ref-pin state into focused tool-ref-pins module Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(mcp): add success-path ref-pin wiring test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): narrow tool-ref-pins result types and replace as-cast with type guard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): remove unnecessary as-casts in tool-ref-pins and command-tools Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): align ref-pin result handling with #1343 typed result projection Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): upstream types for ref-pin module (#1345) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): introduce honest public targetKind-discriminated interaction response contracts Replace the public AgentDeviceClient return types for press/click/fill/longpress/find with serialized-payload-shaped response data (targetKind, flat ref/selector/x/y, per-command extras) instead of internal runtime result types. The internal PressCommandResult/FillCommandResult/LongPressCommandResult keep their kind/target shapes for the daemon runtime; the public CommandResultMap now points to the new response contracts. - Add PressCommandResponseData/FillCommandResponseData/LongPressCommandResponseData/FindCommandResponseData in src/contracts/interaction.ts. - Update CommandResultMap and command-result tests. - Add client-facing shape tests asserting the public response data discriminates on targetKind and exposes flat identity fields. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(contracts): include cost and iOS Maestro fallback fields in public response contracts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 5a33f8b commit 1f042c3

11 files changed

Lines changed: 1214 additions & 680 deletions

File tree

src/__tests__/client.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import assert from 'node:assert/strict';
33
import { mkdtempSync } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6+
import type {
7+
ClickCommandResponseData,
8+
FillCommandResponseData,
9+
FindCommandResponseData,
10+
LongPressCommandResponseData,
11+
PressCommandResponseData,
12+
} from '../contracts/interaction.ts';
613
import {
714
createAgentDeviceClient,
815
type AgentDeviceClient,
@@ -1140,3 +1147,178 @@ test('capture.snapshot passes a digest (non-default level) payload through unnor
11401147
assert.equal(asRecord.nodeCount, 3);
11411148
assert.ok(!('identifiers' in asRecord));
11421149
});
1150+
1151+
test('interactions expose targetKind-discriminated public response data', async () => {
1152+
const setup = createTransport(async (req) => {
1153+
if (req.command === 'press') {
1154+
return {
1155+
ok: true,
1156+
data: {
1157+
targetKind: 'ref',
1158+
ref: 'e5',
1159+
x: 88,
1160+
y: 99,
1161+
message: 'Tapped @e5 (88, 99)',
1162+
},
1163+
};
1164+
}
1165+
if (req.command === 'click') {
1166+
return {
1167+
ok: true,
1168+
data: {
1169+
targetKind: 'point',
1170+
x: 10,
1171+
y: 20,
1172+
button: 'secondary',
1173+
message: 'Tapped (10, 20)',
1174+
},
1175+
};
1176+
}
1177+
if (req.command === 'fill') {
1178+
return {
1179+
ok: true,
1180+
data: {
1181+
targetKind: 'ref',
1182+
ref: 'e5',
1183+
x: 88,
1184+
y: 99,
1185+
text: 'hello',
1186+
message: 'Filled 5 chars',
1187+
},
1188+
};
1189+
}
1190+
if (req.command === 'longpress') {
1191+
return {
1192+
ok: true,
1193+
data: {
1194+
targetKind: 'selector',
1195+
selector: 'label=Foo',
1196+
x: 30,
1197+
y: 40,
1198+
gesture: 'longpress',
1199+
durationMs: 500,
1200+
message: 'Long pressed label=Foo (30, 40)',
1201+
},
1202+
};
1203+
}
1204+
if (req.command === 'find') {
1205+
return {
1206+
ok: true,
1207+
data: { ref: '@e5', refsGeneration: 42, text: 'Hello' },
1208+
};
1209+
}
1210+
throw new Error(`unexpected command: ${req.command}`);
1211+
});
1212+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
1213+
1214+
const press = await client.interactions.press({ ref: '@e5' });
1215+
const click = await client.interactions.click({ x: 10, y: 20, button: 'secondary' });
1216+
const fill = await client.interactions.fill({ ref: '@e5', text: 'hello' });
1217+
const longPress = await client.interactions.longPress({
1218+
selector: 'label=Foo',
1219+
durationMs: 500,
1220+
});
1221+
const find = await client.interactions.find({
1222+
locator: 'label',
1223+
query: 'Foo',
1224+
action: 'getText',
1225+
});
1226+
1227+
const pressType: Equal<typeof press, PressCommandResponseData> = true;
1228+
const clickType: Equal<typeof click, ClickCommandResponseData> = true;
1229+
const fillType: Equal<typeof fill, FillCommandResponseData> = true;
1230+
const longPressType: Equal<typeof longPress, LongPressCommandResponseData> = true;
1231+
const findType: Equal<typeof find, FindCommandResponseData> = true;
1232+
1233+
assert.equal(press.targetKind, 'ref');
1234+
assert.equal(press.ref, 'e5');
1235+
assert.equal(press.x, 88);
1236+
assert.equal(press.y, 99);
1237+
1238+
assert.equal(click.targetKind, 'point');
1239+
assert.equal(click.x, 10);
1240+
assert.equal(click.y, 20);
1241+
assert.equal(click.button, 'secondary');
1242+
1243+
assert.equal(fill.targetKind, 'ref');
1244+
assert.equal(fill.ref, 'e5');
1245+
assert.equal(fill.text, 'hello');
1246+
1247+
assert.equal(longPress.targetKind, 'selector');
1248+
assert.equal(longPress.selector, 'label=Foo');
1249+
assert.equal(longPress.gesture, 'longpress');
1250+
assert.equal(longPress.durationMs, 500);
1251+
1252+
assert.equal(find.ref, '@e5');
1253+
assert.equal(find.refsGeneration, 42);
1254+
assert.equal(find.text, 'Hello');
1255+
1256+
assert.deepEqual(
1257+
[pressType, clickType, fillType, longPressType, findType],
1258+
[true, true, true, true, true],
1259+
);
1260+
});
1261+
1262+
test('interaction responses expose additive cost and direct-iOS Maestro fallback fields', async () => {
1263+
const setup = createTransport(async (req) => {
1264+
if (req.command === 'press') {
1265+
return {
1266+
ok: true,
1267+
data: {
1268+
targetKind: 'ref',
1269+
ref: 'e5',
1270+
cost: { wallClockMs: 123, runnerRoundTrips: 2, nodeCount: 5 },
1271+
},
1272+
};
1273+
}
1274+
if (req.command === 'click') {
1275+
return {
1276+
ok: true,
1277+
data: {
1278+
targetKind: 'selector',
1279+
selector: 'id=hidden',
1280+
maestroNonHittableCoordinateFallbackAllowed: true,
1281+
maestroNonHittableCoordinateFallbackUsed: true,
1282+
maestroFallbackReason: 'non-hittable-coordinate',
1283+
},
1284+
};
1285+
}
1286+
if (req.command === 'find') {
1287+
return {
1288+
ok: true,
1289+
data: {
1290+
ref: '@e5',
1291+
refsGeneration: 42,
1292+
text: 'Hello',
1293+
cost: { wallClockMs: 45, runnerRoundTrips: 0 },
1294+
},
1295+
};
1296+
}
1297+
throw new Error(`unexpected command: ${req.command}`);
1298+
});
1299+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
1300+
1301+
const press = await client.interactions.press({ ref: '@e5', cost: true });
1302+
const click = await client.interactions.click({ selector: 'id=hidden' });
1303+
const find = await client.interactions.find({
1304+
locator: 'label',
1305+
query: 'Foo',
1306+
action: 'getText',
1307+
cost: true,
1308+
});
1309+
1310+
assert.equal(press.targetKind, 'ref');
1311+
assert.equal(press.cost?.wallClockMs, 123);
1312+
assert.equal(press.cost?.runnerRoundTrips, 2);
1313+
assert.equal(press.cost?.nodeCount, 5);
1314+
1315+
assert.equal(click.targetKind, 'selector');
1316+
assert.equal(click.selector, 'id=hidden');
1317+
assert.equal(click.maestroNonHittableCoordinateFallbackAllowed, true);
1318+
assert.equal(click.maestroNonHittableCoordinateFallbackUsed, true);
1319+
assert.equal(click.maestroFallbackReason, 'non-hittable-coordinate');
1320+
1321+
assert.equal(find.ref, '@e5');
1322+
assert.equal(find.cost?.wallClockMs, 45);
1323+
assert.equal(find.cost?.runnerRoundTrips, 0);
1324+
});

src/client/client-types.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ export type CaptureSnapshotResult = {
480480
* token budget); pair a ref with it (`@e12~s<refsGeneration>`) before a mutation.
481481
*/
482482
refsGeneration?: number;
483+
/**
484+
* Digest response view only: a capped list of `{ ref, label? }` pairs taken
485+
* from the full `nodes` tree so the MCP layer can still pin refs when the
486+
* default-level `nodes` payload is intentionally omitted.
487+
*/
488+
refs?: Array<{ ref: string; label?: string }>;
483489
} & PublicSnapshotCaptureAnnotations;
484490

485491
export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & {
@@ -1190,23 +1196,23 @@ export type AgentDeviceClient = {
11901196
diff: (options: CaptureDiffOptions) => Promise<CommandResult<'diff'>>;
11911197
};
11921198
interactions: {
1193-
click: (options: ClickOptions) => Promise<CommandRequestResult>;
1194-
press: (options: PressOptions) => Promise<CommandRequestResult>;
1195-
longPress: (options: LongPressOptions) => Promise<CommandRequestResult>;
1199+
click: (options: ClickOptions) => Promise<CommandResult<'click'>>;
1200+
press: (options: PressOptions) => Promise<CommandResult<'press'>>;
1201+
longPress: (options: LongPressOptions) => Promise<CommandResult<'longpress'>>;
11961202
swipe: (options: SwipeOptions) => Promise<CommandRequestResult>;
11971203
pan: (options: PanOptions) => Promise<CommandRequestResult>;
11981204
fling: (options: FlingOptions) => Promise<CommandRequestResult>;
11991205
swipeGesture: (options: SwipeGestureOptions) => Promise<CommandRequestResult>;
12001206
focus: (options: FocusOptions) => Promise<CommandRequestResult>;
12011207
type: (options: TypeTextOptions) => Promise<CommandRequestResult>;
1202-
fill: (options: FillOptions) => Promise<CommandRequestResult>;
1208+
fill: (options: FillOptions) => Promise<CommandResult<'fill'>>;
12031209
scroll: (options: ScrollOptions) => Promise<CommandRequestResult>;
12041210
pinch: (options: PinchOptions) => Promise<CommandRequestResult>;
12051211
rotateGesture: (options: RotateGestureOptions) => Promise<CommandRequestResult>;
12061212
transformGesture: (options: TransformGestureOptions) => Promise<CommandRequestResult>;
12071213
get: (options: GetOptions) => Promise<CommandRequestResult>;
12081214
is: (options: IsOptions) => Promise<CommandRequestResult>;
1209-
find: (options: FindOptions) => Promise<CommandRequestResult>;
1215+
find: (options: FindOptions) => Promise<CommandResult<'find'>>;
12101216
};
12111217
replay: {
12121218
run: (options: ReplayRunOptions) => Promise<CommandResult<'replay'>>;

src/contracts/interaction.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Point, SnapshotNode } from '../kernel/snapshot.ts';
2+
import type { ResponseCost } from '../kernel/contracts.ts';
3+
import type { ClickButton } from '../core/click-button.ts';
24

35
export type SelectorTarget = {
46
kind: 'selector';
@@ -228,6 +230,93 @@ export type SettleObservation = {
228230
hint?: string;
229231
};
230232

233+
/**
234+
* Public daemon response data shared by press/click/fill/longpress.
235+
* `buildInteractionResponseData` emits this shape (ADR 0011 Layer 2):
236+
* `targetKind` discriminates the resolved target, identity fields are FLAT
237+
* (`ref`, `selector`, `x`, `y`), and per-command extras ride alongside.
238+
*/
239+
type TouchResponseDataBase = {
240+
message?: string;
241+
warning?: string;
242+
x?: number;
243+
y?: number;
244+
referenceWidth?: number;
245+
referenceHeight?: number;
246+
evidence?: InteractionEvidence;
247+
settle?: SettleObservation;
248+
resolution?: ResolutionDisclosure;
249+
cost?: ResponseCost;
250+
/** Direct iOS Maestro coordinate-fallback signals. */
251+
maestroNonHittableCoordinateFallbackAllowed?: boolean;
252+
maestroNonHittableCoordinateFallbackUsed?: boolean;
253+
maestroFallbackReason?: 'non-hittable-coordinate';
254+
};
255+
256+
type TouchResponsePoint = TouchResponseDataBase & {
257+
targetKind: 'point';
258+
x: number;
259+
y: number;
260+
};
261+
262+
type TouchResponseRef = TouchResponseDataBase & {
263+
targetKind: 'ref';
264+
ref: string;
265+
refLabel?: string;
266+
selectorChain?: string[];
267+
targetHittable?: boolean;
268+
hint?: string;
269+
};
270+
271+
type TouchResponseSelector = TouchResponseDataBase & {
272+
targetKind: 'selector';
273+
selector: string;
274+
selectorChain?: string[];
275+
refLabel?: string;
276+
targetHittable?: boolean;
277+
hint?: string;
278+
};
279+
280+
type TouchPressExtras = {
281+
button?: ClickButton;
282+
count?: number;
283+
intervalMs?: number;
284+
holdMs?: number;
285+
jitterPx?: number;
286+
doubleTap?: boolean;
287+
};
288+
289+
export type PressCommandResponseData =
290+
| (TouchResponsePoint & TouchPressExtras)
291+
| (TouchResponseRef & TouchPressExtras)
292+
| (TouchResponseSelector & TouchPressExtras);
293+
294+
export type ClickCommandResponseData = PressCommandResponseData;
295+
296+
type TouchFillExtras = {
297+
text: string;
298+
delayMs?: number;
299+
};
300+
301+
export type FillCommandResponseData =
302+
| (TouchResponsePoint & TouchFillExtras)
303+
| (TouchResponseRef & TouchFillExtras)
304+
| (TouchResponseSelector & TouchFillExtras);
305+
306+
type TouchLongPressExtras = {
307+
durationMs?: number;
308+
gesture: 'longpress';
309+
};
310+
311+
export type LongPressCommandResponseData =
312+
| (TouchResponsePoint & TouchLongPressExtras)
313+
| (TouchResponseRef & TouchLongPressExtras)
314+
| (TouchResponseSelector & TouchLongPressExtras);
315+
316+
/**
317+
* Internal runtime result for press/click. The daemon response layer turns
318+
* this into `PressCommandResponseData` via `buildInteractionResponseData`.
319+
*/
231320
export type PressCommandResult = ResolvedInteractionTarget & {
232321
backendResult?: Record<string, unknown>;
233322
message?: string;
@@ -236,6 +325,10 @@ export type PressCommandResult = ResolvedInteractionTarget & {
236325
settle?: SettleObservation;
237326
};
238327

328+
/**
329+
* Internal runtime result for fill. The daemon response layer turns this into
330+
* `FillCommandResponseData` via `buildInteractionResponseData`.
331+
*/
239332
export type FillCommandResult = ResolvedInteractionTarget & {
240333
text: string;
241334
warning?: string;
@@ -245,10 +338,38 @@ export type FillCommandResult = ResolvedInteractionTarget & {
245338
settle?: SettleObservation;
246339
};
247340

341+
/**
342+
* Internal runtime result for longpress. The daemon response layer turns this
343+
* into `LongPressCommandResponseData` via `buildInteractionResponseData`.
344+
*/
248345
export type LongPressCommandResult = ResolvedInteractionTarget & {
249346
durationMs?: number;
250347
backendResult?: Record<string, unknown>;
251348
message?: string;
252349
warning?: string;
253350
settle?: SettleObservation;
254351
};
352+
353+
/**
354+
* Daemon response data for the `find` command. Read-only actions (`exists`,
355+
* `wait`, `get_text`, `get_attrs`) may issue a pinnable ref with
356+
* `refsGeneration`; mutating actions (`click`, `fill`, `focus`, `type`) carry
357+
* `ref` as diagnostic pre-action identity and intentionally omit `refsGeneration`
358+
* (ADR 0014). The shape is intentionally a flat, optional-field record because
359+
* the action positional changes which fields are present.
360+
*/
361+
export type FindCommandResponseData = {
362+
ref?: string;
363+
refsGeneration?: number;
364+
found?: true;
365+
waitedMs?: number;
366+
text?: string;
367+
node?: SnapshotNode;
368+
locator?: string;
369+
query?: string;
370+
x?: number;
371+
y?: number;
372+
message?: string;
373+
settle?: SettleObservation;
374+
cost?: ResponseCost;
375+
};

0 commit comments

Comments
 (0)