Skip to content

Commit 156bc97

Browse files
committed
fix(web): classify agent-browser dispatch failures from typed backend phases
The managed backend's error unwrapping dropped every dispatch phase, so a web interaction failure reached the wire with no producer verdict and the request router could only answer 'unknown' - even when the agent-browser CLI had refused the argv before the browser was touched, or the managed backend was never spawned. Stamp 'no' from the two typed sources that prove the command never reached the browser: the CLI refusal envelope 'type' (unknown_command, missing_arguments) and the provider's pre-spawn TOOL_MISSING refusal. Count the sends of compound operations - coordinate click is move/down/up, fill adds selection and typing, and a paced scroll sends one wheel step at a time - so a failure after a send returned reports 'unknown' with details.dispatchedSteps through the shared kernel rule. A backend-answered failure keeps no verdict: the command reached the browser and its effect is not knowable from the reply. Adds the web-agent-browser producer and five golden-table rows with a driver that runs each through the real provider entry point.
1 parent 5446b9c commit 156bc97

4 files changed

Lines changed: 335 additions & 28 deletions

File tree

‎contracts/fixtures/dispatch-disclosure.json‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,5 +504,41 @@
504504
"producer": "webdriver",
505505
"trigger": "orientation refused by both the rotation route and the orientation route: each answer said the route is not implemented, so nothing ran",
506506
"dispatched": "no"
507+
},
508+
{
509+
"id": "web-agent-browser.backend-missing",
510+
"producer": "web-agent-browser",
511+
"trigger": "the managed backend entry is not installed, so the provider refuses before spawning the CLI at all",
512+
"dispatched": "no"
513+
},
514+
{
515+
"id": "web-agent-browser.cli-unknown-command",
516+
"producer": "web-agent-browser",
517+
"trigger": "the CLI envelope refuses an unknown command by type: argv parsing never reached the browser daemon",
518+
"dispatched": "no"
519+
},
520+
{
521+
"id": "web-agent-browser.cli-missing-arguments",
522+
"producer": "web-agent-browser",
523+
"trigger": "the CLI envelope refuses a command with missing arguments by type: argv validation, before any browser operation",
524+
"dispatched": "no"
525+
},
526+
{
527+
"id": "web-agent-browser.click-pointer-down-refused",
528+
"producer": "web-agent-browser",
529+
"trigger": "a coordinate click whose mouse move returned and whose mouse down the backend refused: the move landed, so the failure reports unknown with details.dispatchedSteps 1",
530+
"dispatched": "unknown"
531+
},
532+
{
533+
"id": "web-agent-browser.scroll-third-step-refused",
534+
"producer": "web-agent-browser",
535+
"trigger": "a paced scroll whose first two wheel steps returned and whose third the backend refused: the page already scrolled, so the failure reports unknown with details.dispatchedSteps 2",
536+
"dispatched": "unknown"
537+
},
538+
{
539+
"id": "web-agent-browser.fill-selection-press-refused",
540+
"producer": "web-agent-browser",
541+
"trigger": "a coordinate fill whose click (move, down, up) returned and whose select-all press the backend refused: the page already took the click, so the failure reports unknown with details.dispatchedSteps 3",
542+
"dispatched": "unknown"
507543
}
508544
]

‎packages/contracts/src/dispatch-disclosure.fixtures.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const DISPATCH_DISCLOSURE_PRODUCERS = [
99
'android-adb',
1010
'android-helper',
1111
'webdriver',
12+
'web-agent-browser',
1213
'post-action-guard',
1314
] as const;
1415

@@ -52,6 +53,8 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly<Record<string, string>>
5253
'packages/platform-android/src/__tests__/touch-helper-session.test.ts',
5354
'webdriver.': 'packages/provider-webdriver/src/webdriver-transport.test.ts',
5455
'webdriver.orientation.': 'packages/provider-webdriver/src/webdriver-orientation.test.ts',
56+
'web-agent-browser.':
57+
'packages/platform-web/src/__tests__/agent-browser-dispatch-disclosure.test.ts',
5558
'maestro-direct.':
5659
'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts',
5760
};
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// contracts/fixtures/dispatch-disclosure.json, web-agent-browser rows: each drives the real
2+
// provider entry point over a scripted agent-browser CLI and asserts the `details.dispatched`
3+
// (and `details.dispatchedSteps`) the failure leaves behind. The fixtures carry the envelopes the
4+
// managed backend really answers, including its typed CLI-refusal `type`.
5+
import assert from 'node:assert/strict';
6+
import fs from 'node:fs';
7+
import { test, vi } from 'vitest';
8+
import {
9+
assertDispatchDisclosureDriversMatchRows,
10+
DISPATCH_DISCLOSURE_TABLE_PATH,
11+
dispatchDisclosureRowsOwnedBy,
12+
} from '@agent-device/contracts/dispatch-disclosure-fixtures';
13+
import { AppError } from '@agent-device/kernel/errors';
14+
import { withCommandExecutorOverride, type ExecResult } from '@agent-device/host-kit/command';
15+
import {
16+
agentBrowserJsonResult,
17+
mkdtempForTestSync,
18+
withManagedAgentBrowserProvider,
19+
} from './test-utils.ts';
20+
21+
const { providerStartupCleanupMock } = vi.hoisted(() => ({
22+
providerStartupCleanupMock: vi.fn(async () => undefined),
23+
}));
24+
25+
vi.mock('../agent-browser-lifecycle.ts', async (importOriginal) => {
26+
const actual = await importOriginal<typeof import('../agent-browser-lifecycle.ts')>();
27+
return {
28+
...actual,
29+
cleanupManagedAgentBrowserOrphansForProviderStartup: providerStartupCleanupMock,
30+
};
31+
});
32+
33+
import { createAgentBrowserWebProvider } from '../agent-browser-provider.ts';
34+
import type { WebProvider } from '../provider.ts';
35+
36+
/** The managed install exists, but every CLI call is answered by `answer`. */
37+
async function withScriptedBackend(
38+
answer: (cliArgs: readonly string[]) => ExecResult,
39+
runProviderCall: (provider: WebProvider) => Promise<unknown>,
40+
): Promise<void> {
41+
await withManagedAgentBrowserProvider({ session: 'disclosure' }, async (provider) => {
42+
await withCommandExecutorOverride(
43+
async (_cmd: string, args: readonly string[]) => answer(args.slice(1)),
44+
async () => await runProviderCall(provider),
45+
);
46+
});
47+
}
48+
49+
/** Agent-browser answers a command it does not implement before it reaches the browser daemon. */
50+
function refuseUnknownCommand(): ExecResult {
51+
return agentBrowserJsonResult({
52+
error: 'Unknown command: --bogus',
53+
success: false,
54+
type: 'unknown_command',
55+
});
56+
}
57+
58+
/** Agent-browser answers a command with argv it cannot run the same way. */
59+
function refuseMissingArguments(): ExecResult {
60+
return agentBrowserJsonResult({
61+
error: 'Missing arguments for: click\nUsage: agent-browser click <selector>',
62+
success: false,
63+
type: 'missing_arguments',
64+
});
65+
}
66+
67+
/** The envelope the backend answers once it has received and acted on a command. */
68+
function backendRefused(error: string): ExecResult {
69+
return agentBrowserJsonResult({ success: false, data: null, error });
70+
}
71+
72+
/** Answers the pointer sends the given way, and refuses everything else as the backend would. */
73+
function answerPointerSends(
74+
failOn: 'down' | 'up' | 'press',
75+
): (args: readonly string[]) => ExecResult {
76+
return (cliArgs) => {
77+
const command = cliArgs.slice(0, 2).join(' ');
78+
const pointerStepLanded =
79+
command === 'mouse move' ||
80+
(failOn !== 'down' && command === 'mouse down') ||
81+
(failOn !== 'down' && failOn !== 'up' && command === 'mouse up');
82+
if (pointerStepLanded) return agentBrowserJsonResult({ success: true, data: {} }, 0);
83+
if (failOn === 'down' || failOn === 'up') return backendRefused('Element not found.');
84+
return backendRefused('Target node detached.');
85+
};
86+
}
87+
88+
/** Coordinate `click`: the move landed, the press was refused. */
89+
async function clickRefusedOnPointerDown(): Promise<void> {
90+
let moves = 0;
91+
await withScriptedBackend(
92+
(cliArgs) => {
93+
if (cliArgs.slice(0, 2).join(' ') === 'mouse move') moves += 1;
94+
return answerPointerSends('down')(cliArgs);
95+
},
96+
async (provider) => {
97+
await provider.click(20, 30);
98+
assert.equal(moves, 1);
99+
},
100+
);
101+
}
102+
103+
/** Coordinate `fill`: the whole click landed, the select-all press was refused. */
104+
async function fillRefusedAfterClick(): Promise<void> {
105+
let pointerSends = 0;
106+
await withScriptedBackend(
107+
(cliArgs) => {
108+
if (cliArgs[0] === 'mouse') pointerSends += 1;
109+
return answerPointerSends('press')(cliArgs);
110+
},
111+
async (provider) => {
112+
await provider.fill(20, 30, 'Ada');
113+
assert.equal(pointerSends, 3);
114+
},
115+
);
116+
}
117+
118+
/** `scroll --duration` long enough to pace into steps: two landed, the third was refused. */
119+
async function scrollRefusedOnThirdStep(): Promise<void> {
120+
let scrollSteps = 0;
121+
await withScriptedBackend(
122+
(cliArgs) => {
123+
if (cliArgs[0] !== 'scroll') return backendRefused('Unknown ref: e3');
124+
scrollSteps += 1;
125+
return scrollSteps <= 2
126+
? agentBrowserJsonResult({ success: true, data: {} }, 0)
127+
: backendRefused('Scroll target detached.');
128+
},
129+
async (provider) => {
130+
await provider.scroll('down', { pixels: 300, durationMs: 120 });
131+
assert.equal(scrollSteps, 3);
132+
},
133+
);
134+
}
135+
136+
/** The managed backend is not installed, so the CLI is never spawned. */
137+
async function openWithMissingBackend(): Promise<void> {
138+
const provider = createAgentBrowserWebProvider({
139+
stateDir: mkdtempForTestSync('agent-device-web-disclosure-missing-'),
140+
});
141+
await provider.open('https://example.test');
142+
}
143+
144+
/** One row's drive plus the `details.dispatchedSteps` it additionally demands. */
145+
const DRIVERS: Record<string, { drive: () => Promise<unknown>; dispatchedSteps?: number }> = {
146+
'web-agent-browser.backend-missing': { drive: openWithMissingBackend },
147+
'web-agent-browser.cli-unknown-command': {
148+
drive: () =>
149+
withScriptedBackend(refuseUnknownCommand, async (provider) => {
150+
await provider.clickRef?.('@e3');
151+
}),
152+
},
153+
'web-agent-browser.cli-missing-arguments': {
154+
drive: () =>
155+
withScriptedBackend(refuseMissingArguments, async (provider) => {
156+
await provider.clickRef?.('@e3');
157+
}),
158+
},
159+
'web-agent-browser.click-pointer-down-refused': {
160+
drive: clickRefusedOnPointerDown,
161+
dispatchedSteps: 1,
162+
},
163+
'web-agent-browser.scroll-third-step-refused': {
164+
drive: scrollRefusedOnThirdStep,
165+
dispatchedSteps: 2,
166+
},
167+
'web-agent-browser.fill-selection-press-refused': {
168+
drive: fillRefusedAfterClick,
169+
dispatchedSteps: 3,
170+
},
171+
};
172+
173+
const ROWS = dispatchDisclosureRowsOwnedBy(
174+
import.meta.url,
175+
fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'),
176+
);
177+
178+
test('every web-agent-browser dispatch-disclosure row has exactly one driver', () => {
179+
assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS));
180+
});
181+
182+
for (const row of ROWS) {
183+
test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => {
184+
const driver = DRIVERS[row.id];
185+
assert.ok(driver, `no driver for ${row.id}`);
186+
await assert.rejects(
187+
async () => await driver.drive(),
188+
(error: unknown) => {
189+
assert.ok(error instanceof AppError);
190+
assert.equal(error.details?.dispatched, row.dispatched);
191+
assert.equal(error.details?.dispatchedSteps, driver.dispatchedSteps);
192+
return true;
193+
},
194+
);
195+
});
196+
}
197+
198+
test('a backend-answered failure carries no provider verdict for the router to override', async () => {
199+
// The rows above prove what the provider stamps; this pins the complement, because a provider
200+
// that guessed `no` from the message text here would make a blind retry unsafe: the backend
201+
// received the click, and only the request router may default the verdict.
202+
await withScriptedBackend(
203+
() => backendRefused('Unknown ref: e3'),
204+
async (provider) => {
205+
await provider.clickRef?.('@e3');
206+
},
207+
).then(
208+
() => assert.fail('the refusal must reject'),
209+
(error: unknown) => {
210+
assert.ok(error instanceof AppError);
211+
assert.equal(error.details?.dispatched, undefined);
212+
},
213+
);
214+
});

0 commit comments

Comments
 (0)