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
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/preview/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>

const PreviewStatusTool = Tool.make("preview_status", {
description:
"Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.",
"Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Server hosts report serverPageMode (native or headless) for the current tab and tabs list; absence means the host did not report the backing, not that it is native. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.",
parameters: PreviewAutomationTabTargetInput,
success: PreviewAutomationStatus,
failure: PreviewToolFailure,
Expand All @@ -78,7 +78,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
const PreviewOpenTool = browserTool(
Tool.make("preview_open", {
description:
"Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile and its saved logins; omit it for the user's default profile. Another agent session's tabs can be read but not operated.",
"Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile; omit it for the user's default profile. To recover a desktop profile's existing sign-ins after a headless fallback, pass its profileId explicitly with open=true and reuseExistingTab=false. On a desktop server, this new foreground tab visits the requested URL only after connecting to the native page; attachment failure closes the new blank tab and preserves previous tabs. Incognito, background, and standalone server opens retain headless support. Another agent session's tabs can be read but not operated.",
parameters: PreviewAutomationOpenInput,
success: PreviewAutomationStatus,
failure: PreviewToolFailure,
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,89 @@ it.layer(layer)("PreviewManager", (it) => {
}),
);

it.effect("publishes the server's page mode once and preserves a headless choice", () =>
Effect.gen(function* () {
const threadId = freshThreadId();
const manager = yield* PreviewManager.PreviewManager;
const collector = yield* collectEvents;
const opened = yield* manager.open({
threadId,
runtime: "server",
url: "http://localhost:5173/",
profileId: "work",
automationOwner: "agent",
});
const native = yield* manager.setServerPageMode({
threadId,
tabId: opened.tabId,
mode: "native",
});
const headless = yield* manager.setServerPageMode({
threadId,
tabId: opened.tabId,
mode: "headless",
});
expect(native.serverPageMode).toBe("native");
expect(headless).toMatchObject({
...opened,
serverPageMode: "headless",
updatedAt: expect.any(String),
});
const before = yield* manager.list({ threadId });
for (const mode of ["headless", "native"] as const) {
expect(yield* manager.setServerPageMode({ threadId, tabId: opened.tabId, mode })).toEqual(
headless,
);
}
const after = yield* manager.list({ threadId });
expect(after).toEqual(before);
const events = yield* collector.drain;
expect(events.map((event) => event.type)).toEqual(["opened", "resized", "resized"]);
expect(events.at(-1)).toMatchObject({ snapshot: headless, revision: after.revision });
yield* manager.reportStatus({
threadId,
tabId: opened.tabId,
serverControlled: true,
navStatus: { _tag: "Success", url: "http://localhost:5173/next", title: "Next" },
canGoBack: true,
canGoForward: false,
});
yield* manager.resize({ threadId, tabId: opened.tabId, viewport: { _tag: "fill" } });
expect((yield* manager.list({ threadId })).sessions[0]?.serverPageMode).toBe("headless");
}),
);

it.effect(
"allows only server sessions to select a page mode, and never recreates a closed tab",
() =>
Effect.gen(function* () {
const threadId = freshThreadId();
const manager = yield* PreviewManager.PreviewManager;
const desktop = yield* manager.open({ threadId, serverPageMode: "headless" });
expect(desktop).not.toHaveProperty("serverPageMode");
expect(
(yield* manager
.setServerPageMode({ threadId, tabId: desktop.tabId, mode: "headless" })
.pipe(Effect.flip))._tag,
).toBe("PreviewControlRequiredError");
const server = yield* manager.open({
threadId,
runtime: "server",
serverPageMode: "headless",
});
expect(server.serverPageMode).toBe("headless");
yield* manager.close({ threadId, tabId: server.tabId });
expect(
(yield* manager
.setServerPageMode({ threadId, tabId: server.tabId, mode: "headless" })
.pipe(Effect.flip))._tag,
).toBe("PreviewSessionLookupError");
expect(
(yield* manager.list({ threadId })).sessions.map((session) => session.tabId),
).toEqual([desktop.tabId]);
}),
);

it.effect("lets any client size, theme, and zoom a server tab", () =>
Effect.gen(function* () {
const threadId = freshThreadId();
Expand Down
50 changes: 49 additions & 1 deletion apps/server/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export class PreviewManager extends Context.Service<
readonly open: (
input: PreviewOpenInput & {
readonly automationOwner?: string;
/** Only the server browser selects how its page is presented. */
readonly serverPageMode?: PreviewSessionSnapshot["serverPageMode"];
/** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */
readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void;
},
Expand All @@ -58,6 +60,13 @@ export class PreviewManager extends Context.Service<
readonly reportStatus: (
input: PreviewReportStatusInput & { readonly serverControlled?: boolean },
) => Effect.Effect<void, PreviewError>;
/** Publishes the backing page selected by the server; headless cannot be promoted later. */
readonly setServerPageMode: (
input: PreviewCloseInput & {
readonly tabId: string;
readonly mode: "native" | "headless";
},
) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
readonly requestReveal: (
input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean },
) => Effect.Effect<void, PreviewError>;
Expand Down Expand Up @@ -221,6 +230,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {
viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT,
...(input.profileId === undefined ? {} : { profileId: input.profileId }),
...(runtime === undefined ? {} : { runtime }),
...(runtime === "server" && input.serverPageMode !== undefined
? { serverPageMode: input.serverPageMode }
: {}),
...(runtime === "server" && input.automationOwner !== undefined
? { automationOwner: input.automationOwner }
: {}),
Expand Down Expand Up @@ -337,6 +349,40 @@ export const make = Effect.gen(function* PreviewManagerMake() {
);
});

const setServerPageMode: PreviewManager["Service"]["setServerPageMode"] = Effect.fn(
"PreviewManager.setServerPageMode",
)(function* (input) {
return yield* mutateExistingSession(
input.threadId,
input.tabId,
Effect.fn("PreviewManager.setSessionPageMode")(function* (session) {
if (session.snapshot.runtime !== "server")
return yield* new PreviewControlRequiredError({ tabId: input.tabId });
if (
session.snapshot.serverPageMode === input.mode ||
session.snapshot.serverPageMode === "headless"
)
return { next: session, emit: null, result: session.snapshot };
const snapshot: PreviewSessionSnapshot = {
...session.snapshot,
serverPageMode: input.mode,
updatedAt: yield* currentIsoTimestamp,
};
return {
next: { ...session, snapshot },
emit: {
type: "resized",
threadId: session.threadId,
tabId: session.tabId,
createdAt: snapshot.updatedAt,
snapshot,
},
result: snapshot,
};
}),
);
});

const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")(
function* (input) {
return yield* mutateExistingSession(
Expand Down Expand Up @@ -456,7 +502,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {

const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")(
function* (input) {
return yield* SynchronizedRef.get(stateRef).pipe(
// Events publish inside a mutation; their consumers must wait for its state commit.
return yield* SynchronizedRef.modify(stateRef, (state) => [state, state] as const).pipe(
Effect.map((state): PreviewListResult => ({
sessions: sessionsForThread(state, input.threadId)
.map((s) => s.snapshot)
Expand Down Expand Up @@ -504,6 +551,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
requestReveal,
navigate,
reportStatus,
setServerPageMode,
resize,
adjust,
refresh,
Expand Down
Loading
Loading