Skip to content

Commit bbe98e6

Browse files
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>
1 parent 91daaad commit bbe98e6

4 files changed

Lines changed: 230 additions & 31 deletions

File tree

src/__tests__/client.test.ts

Lines changed: 118 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,114 @@ 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+
});

src/contracts/interaction.ts

Lines changed: 91 additions & 15 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,46 +230,120 @@ export type SettleObservation = {
228230
hint?: string;
229231
};
230232

231-
export type PressCommandResult = ResolvedInteractionTarget & {
232-
backendResult?: Record<string, unknown>;
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 = {
233240
message?: string;
234241
warning?: string;
235-
evidence?: InteractionEvidence;
236-
settle?: SettleObservation;
237-
// Public response fields added by the daemon response layer.
238242
x?: number;
239243
y?: number;
240-
button?: string;
244+
referenceWidth?: number;
245+
referenceHeight?: number;
246+
evidence?: InteractionEvidence;
247+
settle?: SettleObservation;
248+
resolution?: ResolutionDisclosure;
249+
cost?: ResponseCost;
250+
};
251+
252+
type TouchResponsePoint = TouchResponseDataBase & {
253+
targetKind: 'point';
254+
x: number;
255+
y: number;
256+
};
257+
258+
type TouchResponseRef = TouchResponseDataBase & {
259+
targetKind: 'ref';
260+
ref: string;
261+
refLabel?: string;
262+
selectorChain?: string[];
263+
targetHittable?: boolean;
264+
hint?: string;
265+
};
266+
267+
type TouchResponseSelector = TouchResponseDataBase & {
268+
targetKind: 'selector';
269+
selector: string;
270+
selectorChain?: string[];
271+
refLabel?: string;
272+
targetHittable?: boolean;
273+
hint?: string;
274+
};
275+
276+
type TouchPressExtras = {
277+
button?: ClickButton;
241278
count?: number;
242279
intervalMs?: number;
243280
holdMs?: number;
244281
jitterPx?: number;
245282
doubleTap?: boolean;
246283
};
247284

285+
export type PressCommandResponseData =
286+
| (TouchResponsePoint & TouchPressExtras)
287+
| (TouchResponseRef & TouchPressExtras)
288+
| (TouchResponseSelector & TouchPressExtras);
289+
290+
export type ClickCommandResponseData = PressCommandResponseData;
291+
292+
type TouchFillExtras = {
293+
text: string;
294+
delayMs?: number;
295+
};
296+
297+
export type FillCommandResponseData =
298+
| (TouchResponsePoint & TouchFillExtras)
299+
| (TouchResponseRef & TouchFillExtras)
300+
| (TouchResponseSelector & TouchFillExtras);
301+
302+
type TouchLongPressExtras = {
303+
durationMs?: number;
304+
gesture: 'longpress';
305+
};
306+
307+
export type LongPressCommandResponseData =
308+
| (TouchResponsePoint & TouchLongPressExtras)
309+
| (TouchResponseRef & TouchLongPressExtras)
310+
| (TouchResponseSelector & TouchLongPressExtras);
311+
312+
/**
313+
* Internal runtime result for press/click. The daemon response layer turns
314+
* this into `PressCommandResponseData` via `buildInteractionResponseData`.
315+
*/
316+
export type PressCommandResult = ResolvedInteractionTarget & {
317+
backendResult?: Record<string, unknown>;
318+
message?: string;
319+
warning?: string;
320+
evidence?: InteractionEvidence;
321+
settle?: SettleObservation;
322+
};
323+
324+
/**
325+
* Internal runtime result for fill. The daemon response layer turns this into
326+
* `FillCommandResponseData` via `buildInteractionResponseData`.
327+
*/
248328
export type FillCommandResult = ResolvedInteractionTarget & {
249329
text: string;
250330
warning?: string;
251331
backendResult?: Record<string, unknown>;
252332
message?: string;
253333
evidence?: InteractionEvidence;
254334
settle?: SettleObservation;
255-
// Public response fields added by the daemon response layer.
256-
x?: number;
257-
y?: number;
258-
delayMs?: number;
259335
};
260336

337+
/**
338+
* Internal runtime result for longpress. The daemon response layer turns this
339+
* into `LongPressCommandResponseData` via `buildInteractionResponseData`.
340+
*/
261341
export type LongPressCommandResult = ResolvedInteractionTarget & {
262342
durationMs?: number;
263343
backendResult?: Record<string, unknown>;
264344
message?: string;
265345
warning?: string;
266346
settle?: SettleObservation;
267-
// Public response fields added by the daemon response layer.
268-
x?: number;
269-
y?: number;
270-
gesture?: 'longpress';
271347
};
272348

273349
/**
@@ -278,7 +354,7 @@ export type LongPressCommandResult = ResolvedInteractionTarget & {
278354
* (ADR 0014). The shape is intentionally a flat, optional-field record because
279355
* the action positional changes which fields are present.
280356
*/
281-
export type FindCommandResult = {
357+
export type FindCommandResponseData = {
282358
ref?: string;
283359
refsGeneration?: number;
284360
found?: true;

src/core/command-descriptor/__tests__/command-result.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { expect, test } from 'vitest';
22
import type {
3-
FillCommandResult,
4-
LongPressCommandResult,
5-
PressCommandResult,
3+
FillCommandResponseData,
4+
LongPressCommandResponseData,
5+
PressCommandResponseData,
6+
FindCommandResponseData,
67
} from '../../../contracts/interaction.ts';
78
import type { BootCommandResult, ShutdownCommandResult } from '../../../contracts/device.ts';
89
import type { ViewportCommandResult } from '../../../contracts/viewport.ts';
@@ -36,10 +37,11 @@ type Equal<A, B> =
3637
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
3738

3839
test('seeded CommandResult entries resolve to their existing contract result types', () => {
39-
const press: Equal<CommandResult<'press'>, PressCommandResult> = true;
40-
const click: Equal<CommandResult<'click'>, PressCommandResult> = true;
41-
const fill: Equal<CommandResult<'fill'>, FillCommandResult> = true;
42-
const longPress: Equal<CommandResult<'longpress'>, LongPressCommandResult> = true;
40+
const press: Equal<CommandResult<'press'>, PressCommandResponseData> = true;
41+
const click: Equal<CommandResult<'click'>, PressCommandResponseData> = true;
42+
const fill: Equal<CommandResult<'fill'>, FillCommandResponseData> = true;
43+
const longPress: Equal<CommandResult<'longpress'>, LongPressCommandResponseData> = true;
44+
const find: Equal<CommandResult<'find'>, FindCommandResponseData> = true;
4345
const boot: Equal<CommandResult<'boot'>, BootCommandResult> = true;
4446
const shutdown: Equal<CommandResult<'shutdown'>, ShutdownCommandResult> = true;
4547
const viewport: Equal<CommandResult<'viewport'>, ViewportCommandResult> = true;
@@ -69,6 +71,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
6971
click,
7072
fill,
7173
longPress,
74+
find,
7275
boot,
7376
shutdown,
7477
viewport,
@@ -116,6 +119,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
116119
true,
117120
true,
118121
true,
122+
true,
119123
]);
120124
});
121125

src/core/command-descriptor/command-result.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type {
2-
FillCommandResult,
3-
FindCommandResult,
4-
LongPressCommandResult,
5-
PressCommandResult,
2+
ClickCommandResponseData,
3+
FillCommandResponseData,
4+
FindCommandResponseData,
5+
LongPressCommandResponseData,
6+
PressCommandResponseData,
67
} from '../../contracts/interaction.ts';
78
import type { BootCommandResult, ShutdownCommandResult } from '../../contracts/device.ts';
89
import type { ViewportCommandResult } from '../../contracts/viewport.ts';
@@ -45,11 +46,11 @@ import type { ReplayCommandResult, ReplaySuiteResult } from '../../contracts/rep
4546
* re-read of the handler's literal return; see the per-type docstrings.
4647
*/
4748
export interface CommandResultMap {
48-
press: PressCommandResult;
49-
click: PressCommandResult;
50-
fill: FillCommandResult;
51-
longpress: LongPressCommandResult;
52-
find: FindCommandResult;
49+
press: PressCommandResponseData;
50+
click: ClickCommandResponseData;
51+
fill: FillCommandResponseData;
52+
longpress: LongPressCommandResponseData;
53+
find: FindCommandResponseData;
5354
boot: BootCommandResult;
5455
shutdown: ShutdownCommandResult;
5556
viewport: ViewportCommandResult;

0 commit comments

Comments
 (0)