Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
131 changes: 103 additions & 28 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,34 +513,55 @@ it.effect.each([
);

it.effect.each([
{ clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true },
{ clientId: "client-1", shown: false },
])("tells the agent why its own server browser failed ($clientId)", ({ clientId, shown }) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const requests = requestsFrom(yield* broker.connect(makeHost({ clientId })));
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: false,
error: {
_tag: "PreviewAutomationExecutionError",
message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/",
},
}),
).pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const error = yield* broker
.invoke<void>({ scope, operation: "open", input: {} })
.pipe(Effect.flip);
expect(error._tag).toBe("PreviewAutomationExecutionError");
// A desktop or other remote host's text stays out of the agent's context.
expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown);
}),
),
{
clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID,
tag: "PreviewAutomationExecutionError",
reason: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/",
shown: true,
},
{
clientId: "client-1",
tag: "PreviewAutomationExecutionError",
reason: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/",
shown: false,
},
{
clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID,
tag: "PreviewAutomationTimeoutError",
reason: '<div class="overlay"></div> intercepts pointer events',
shown: true,
},
{
clientId: "client-1",
tag: "PreviewAutomationTimeoutError",
reason: '<div class="overlay"></div> intercepts pointer events',
shown: false,
},
] as const)(
"tells the agent why its own server browser failed ($tag, $clientId)",
({ clientId, tag, reason, shown }) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const requests = requestsFrom(yield* broker.connect(makeHost({ clientId })));
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: false,
error: { _tag: tag, message: reason },
}),
).pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const error = yield* broker
.invoke<void>({ scope, operation: "open", input: {} })
.pipe(Effect.flip);
expect(error._tag).toBe(tag);
// A desktop or other remote host's text stays out of the agent's context.
expect(error.message.includes(reason)).toBe(shown);
}),
),
);

it.effect("distinguishes malformed remote failures", () =>
Expand Down Expand Up @@ -1504,3 +1525,57 @@ it.effect("keeps the host connected when a background status read times out", ()
}),
),
);

it.effect.each([
{ clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, kept: true },
// Preferred routing alone does not make a remote host in-process.
{ clientId: "client-1", kept: false },
])(
"keeps only the server's own browser and the agent's tab when an action times out ($clientId)",
({ clientId, kept }) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const tabId = PreviewTabId.make("tab-server");
const received = yield* Deferred.make<void>();
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId }), { preferred: true }),
);
// A slow page never answers its snapshot; everything else answers with its tab.
yield* Stream.runForEach(requests, (request) =>
request.operation === "snapshot"
? Deferred.succeed(received, undefined)
: broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: { tabId: request.tabId ?? tabId },
}),
).pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* broker.invoke({ scope, operation: "open", input: {} });
const timedOut = yield* broker
.invoke<void>({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 })
.pipe(Effect.flip, Effect.forkScoped);
yield* Deferred.await(received);
yield* TestClock.adjust(2_000);
expect(yield* Fiber.join(timedOut)).toMatchObject({
_tag: "PreviewAutomationTimeoutError",
});

const click = broker.invoke<{ readonly tabId: string }>({
scope,
operation: "click",
input: {},
});
if (kept) expect(yield* click).toEqual({ tabId });
else {
expect(yield* Effect.flip(click)).toMatchObject({
_tag: "PreviewAutomationNoAvailableHostError",
});
}
}),
),
);
19 changes: 14 additions & 5 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ const classifyResponseError = (
...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }),
cause: error,
};
// The server's own browser writes these; other hosts' text stays out of the agent's context.
const serverReason =
context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID
? { reason: error.message.slice(0, MAX_REASON_CHARS) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: {};
switch (error._tag) {
case "PreviewAutomationRecordingDesktopUpdateRequiredError":
return new PreviewAutomationRecordingDesktopUpdateRequiredError({
Expand Down Expand Up @@ -263,6 +268,7 @@ const classifyResponseError = (
return new PreviewAutomationTimeoutError({
...context,
...remoteDiagnostics,
...serverReason,
});
case "PreviewAutomationControlInterruptedError": {
const reason = decodeControlReason(error.detail);
Expand Down Expand Up @@ -340,10 +346,7 @@ const classifyResponseError = (
return new PreviewAutomationExecutionError({
...context,
...remoteDiagnostics,
// The server's own browser writes these; other hosts' text stays out of the agent's context.
...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID
? { reason: error.message.slice(0, MAX_REASON_CHARS) }
: {}),
...serverReason,
});
}
};
Expand Down Expand Up @@ -665,7 +668,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
// actions: the client may have applied them before becoming unreachable.
// A background metadata read has a short budget and changes nothing,
// so a slow one must not cut the host off from the agent's next call.
if (input.updateCurrentTab !== false) {
// The server's own browser is in-process: a slow page there is not
// an unreachable host, and evicting it would drop every thread's
// tab assignment and in-flight request at once.
if (
input.updateCurrentTab !== false &&
connection.clientId !== SERVER_BROWSER_AUTOMATION_CLIENT_ID
) {
yield* disconnect(connection.clientId, connection.queue, true);
}
return yield* new PreviewAutomationTimeoutError(requestContext);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2451,7 +2451,7 @@ const make = Effect.gen(function* () {
),
),
);
// The broker disconnects timed-out hosts, including slow first installs. Reconnect.
// The stream ends if the broker replaces or drops this host. Reconnect.
yield* hostSession.pipe(
Effect.exit,
Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)),
Expand Down
52 changes: 51 additions & 1 deletion apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import {
type CDPSession,
type Page,
} from "playwright-core";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";

import { presentAsChrome } from "./ServerBrowserContexts.ts";
import * as ServerBrowserPage from "./ServerBrowserPage.ts";
Expand Down Expand Up @@ -276,6 +285,18 @@ describe("server browser element refs", () => {
).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" });
expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);
});

it("tells the agent what blocks a click that times out", async () => {
await page.setContent(
`<button>Save</button><div class="overlay" style="position:fixed;inset:0"></div>`,
);
const failure = await ServerBrowserPage.click(page, {
selector: "button",
timeoutMs: 1_500,
}).then(() => undefined, ServerBrowserPage.toOperationError);
expect(failure).toMatchObject({ tag: "PreviewAutomationTimeoutError" });
expect(failure?.message).toMatch(/<div class="overlay".*intercepts pointer events/);
});
});

describe("server browser drag", () => {
Expand Down Expand Up @@ -319,3 +340,32 @@ describe("server browser drag", () => {
}
});
});

describe("server browser snapshot", () => {
afterEach(() => {
vi.useRealTimers();
});

it("fails a snapshot of a page that never answers, freeing the tab for the next request", async () => {
vi.useFakeTimers();
const never = () => new Promise<never>(() => {});
// A page whose main thread is stuck: nothing it is asked ever settles.
const page = {
viewportSize: () => ({ width: 800, height: 600 }),
evaluate: never,
ariaSnapshot: never,
on: () => {},
} as unknown as Page;
const cdp = { send: never } as unknown as CDPSession;
const settled = ServerBrowserPage.snapshot({
page,
cdp,
renderScale: 1,
consoleEntries: [],
networkEntries: [],
actionTimeline: [],
}).then(() => null, ServerBrowserPage.toOperationError);
await vi.advanceTimersByTimeAsync(15_000);
expect(await settled).toMatchObject({ tag: "PreviewAutomationTimeoutError" });
});
});
Loading
Loading