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
114 changes: 113 additions & 1 deletion apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PubSub from "effect/PubSub";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import type { BrowserContext, Page } from "playwright-core";
import { beforeEach, expect, vi } from "vite-plus/test";
Expand Down Expand Up @@ -349,7 +351,7 @@ it.live.each([
])("page watch cleans up before detaching CDP: %j", ({ baseline, reason, cleanupThrows }) =>
Effect.scoped(
Effect.gen(function* () {
const { broker, tabId } = yield* ready;
const { browser, broker, tabId } = yield* ready;
const source = new EventTarget();
const realm = NodeVM.createContext({ TextEncoder, source, callbacks: 0, cleanups: 0 });
const events = new NodeEvents.EventEmitter();
Expand Down Expand Up @@ -411,6 +413,41 @@ it.live.each([
source.dispatchEvent(new Event("change"));
expect(realm.callbacks).toBe(1);
yield* broker.invoke({ scope, tabId, operation: "watchStop", input: started });
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
yield* viewer.input({ type: "takeControl" });
const before = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
const retained = yield* browser.readWatch!(
{
...started,
threadId: scope.thread.threadId,
after: 0,
timeoutMs: 0,
},
Effect.void,
).pipe(Effect.flip);
expect(retained).toMatchObject({ reason: "stopped" });
yield* broker.invoke({ scope, tabId, operation: "watchStop", input: started });
const refused = yield* broker
.invoke<void>({
scope,
tabId,
operation: "watchStart",
input: { subscription: "(emit) => emit(1)" },
})
.pipe(Effect.flip);
expect(refused).toMatchObject({ reason: "humanControl" });
const after = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(after.control).toEqual(before.control);
}
expect(realm.cleanups).toBe(1);
const callbacks = realm.callbacks;
Expand Down Expand Up @@ -1770,6 +1807,81 @@ it.live("agents read a human's tab with no arguments and act on it only while no
).pipe(Effect.provide(layer)),
);

it.live("an agent's tab stays human-controlled across viewer disconnects and passive reads", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
const viewerScope = yield* Scope.make();
const viewer = yield* browser
.attachViewer(viewerInput(tabId, true))
.pipe(Effect.provideService(Scope.Scope, viewerScope));
yield* viewer.input({ type: "takeControl" });
const status = () =>
broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect((yield* status()).control?.owner).toBe("human");
yield* Scope.close(viewerScope, Exit.void);
expect((yield* status()).control?.owner).toBe("human");
const generation = (yield* status()).control?.generation;
yield* broker.invoke({ scope, tabId, operation: "snapshot", input: {} });
const refused = yield* broker
.invoke<void>({
scope,
tabId,
operation: "navigate",
input: { url: "https://example.com" },
})
.pipe(Effect.flip);
expect(refused).toMatchObject({ reason: "humanControl" });
expect((yield* status()).control).toMatchObject({ owner: "human", generation });
const reconnected = yield* browser.attachViewer(viewerInput(tabId, true));
expect((yield* status()).control?.owner).toBe("human");
expect(yield* Queue.clear(reconnected.output)).toContainEqual(
expect.objectContaining({ _tag: "control", controller: "unclaimed", canOperate: true }),
);
yield* reconnected.input({ type: "takeControl" });
yield* reconnected.input({ type: "text", text: "human input" });
yield* reconnected.input({ type: "releaseControl" });
expect((yield* status()).control?.owner).toBe("agent");
yield* broker.invoke({
scope,
tabId,
operation: "navigate",
input: { url: "https://example.com" },
});
}),
).pipe(Effect.provide(layer)),
);

it.live("an agent can snapshot its own tab while a human drives without changing control", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
yield* viewer.input({ type: "takeControl" });
const before = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
yield* broker.invoke({ scope, tabId, operation: "snapshot", input: {} });
const after = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(after.control).toEqual(before.control);
expect(after.control?.owner).toBe("human");
}),
).pipe(Effect.provide(layer)),
);

it.live("a session's own tab stays its default while other sessions' tabs are listed", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
18 changes: 8 additions & 10 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,11 +758,13 @@ const make = Effect.gen(function* () {
controller:
tab.control.controller === viewer.id
? "you"
: tab.control.controller !== null
? "another-viewer"
: tab.control.agentId !== null
? "agent"
: "unclaimed",
: tab.control.humanDisconnected
? "unclaimed"
: tab.control.controller !== null
? "another-viewer"
: tab.control.agentId !== null
? "agent"
: "unclaimed",
generation: tab.control.generation,
dialog: dialogStatus(tab),
});
Expand Down Expand Up @@ -2531,11 +2533,7 @@ const make = Effect.gen(function* () {
}
const agentSessionId = request.agentSessionId!;

if (
READ_OPERATIONS.has(request.operation) &&
tab.control.agentId !== agentSessionId &&
!tab.control.agentCanActNow(agentSessionId)
) {
if (READ_OPERATIONS.has(request.operation) && !tab.control.agentCanActNow(agentSessionId)) {
return tab.control.observe(async () => {
await authorize();
if (tab.dialog)
Expand Down
32 changes: 29 additions & 3 deletions apps/server/src/preview/SessionControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ describe("SessionControl", () => {
expect(control.controller).toBe("lawful");
});

it("keeps disconnected human control when a replacement takeover fails authorization", async () => {
const control = new SessionControl("agent");
await control.take("viewer");
await control.disconnect("viewer");
let checks = 0;
await expect(
control.take("replacement", async () => {
if (++checks > 1) throw new Error("authorization retired");
}),
).rejects.toThrow("authorization retired");
expect(control.humanDisconnected).toBe(true);
await expect(control.agent("agent", async () => "resumed")).rejects.toMatchObject({
reason: "humanControl",
});
await control.take("human");
await control.release("human");
await expect(control.agent("agent", async () => "resumed")).resolves.toBe("resumed");
});

it("returns an action result before tracked navigation but drains it before already queued actions", async () => {
const control = new SessionControl("agent");
const committed = Promise.withResolvers<void>();
Expand Down Expand Up @@ -216,12 +235,19 @@ describe("SessionControl", () => {
control.human("viewer", async () => events.push("stale human action")),
).rejects.toThrow(BrowserControlInterrupted);
const disconnected = control.disconnect("viewer");
const resumed = control.agent("agent", async () => events.push("agent resumed"));
const refused = expect(
control.agent("agent", async () => events.push("agent resumed")),
).rejects.toMatchObject({ reason: "humanControl" });
expect(control.generation).toBe(2);
expect(invalidations).toBe(2);
finish.resolve();
await Promise.all([running, queued, disconnected, resumed]);
expect(events).toEqual(["human finished", "agent resumed"]);
await Promise.all([running, queued, disconnected, refused]);
expect(events).toEqual(["human finished"]);
await control.take("reconnected");
await control.human("reconnected", async () => events.push("human reconnected"));
await control.release("reconnected");
await control.agent("agent", async () => events.push("agent resumed"));
expect(events).toEqual(["human finished", "human reconnected", "agent resumed"]);
});

it("does not revive stale actions when takeover is released before running work finishes", async () => {
Expand Down
24 changes: 19 additions & 5 deletions apps/server/src/preview/SessionControl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { PreviewAutomationControlReason } from "@t3tools/contracts";

const DISCONNECTED_HUMAN = Symbol("disconnected human");

export class BrowserControlInterrupted extends Error {
readonly reason: PreviewAutomationControlReason;
constructor(
Expand All @@ -17,7 +19,7 @@ export class SessionControl {
private readonly onGenerationChange: () => void;
private tail: Promise<unknown> = Promise.resolve();
private pending: Promise<void> = Promise.resolve();
private owner: string | null = null;
private owner: string | typeof DISCONNECTED_HUMAN | null = null;
private epoch = 0;
private closed = false;
private readonly inputs = {
Expand All @@ -35,6 +37,10 @@ export class SessionControl {
return this.owner;
}

get humanDisconnected() {
return this.owner === DISCONNECTED_HUMAN;
}

get generation() {
return this.epoch;
}
Expand Down Expand Up @@ -83,7 +89,7 @@ export class SessionControl {
if (this.closed) throw new BrowserControlInterrupted("This browser tab is closed.", "closed");
}

private changeOwner(owner: string | null) {
private changeOwner(owner: string | typeof DISCONNECTED_HUMAN | null) {
this.owner = owner;
this.epoch++;
this.onGenerationChange();
Expand Down Expand Up @@ -155,9 +161,10 @@ export class SessionControl {
if (authorize) await authorize();
this.assertOpen();
if (this.epoch !== admittedGeneration) throw new BrowserControlInterrupted();
if (this.owner !== null && this.owner !== viewerId) {
if (this.owner !== null && this.owner !== DISCONNECTED_HUMAN && this.owner !== viewerId) {
throw new BrowserControlInterrupted("Another viewer controls this browser tab.");
}
const previousOwner = this.owner;
const changed = this.owner !== viewerId;
if (changed) this.changeOwner(viewerId);
const generation = this.epoch;
Expand All @@ -166,7 +173,8 @@ export class SessionControl {
await this.human(viewerId, async () => {}, authorize);
} catch (cause) {
// Retire only this failed reservation, never a later takeover or reopening.
if (changed && this.owner === viewerId && this.epoch === generation) this.changeOwner(null);
if (changed && this.owner === viewerId && this.epoch === generation)
this.changeOwner(previousOwner);
throw cause;
}
}
Expand All @@ -183,7 +191,13 @@ export class SessionControl {
}

async disconnect(viewerId: string, afterDrain?: () => Promise<void>) {
if (!this.closed && this.owner === viewerId) await this.release(viewerId, afterDrain);
if (!this.closed && this.owner === viewerId) {
// A lost stream retires its input, but only an explicit human release resumes agents.
this.changeOwner(DISCONNECTED_HUMAN);
await this.enqueue(async () => {
await afterDrain?.();
});
}
}

async close() {
Expand Down
2 changes: 1 addition & 1 deletion docs/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ flowchart TD
end

PBI["Preview browser identity"]
%% PBI: #460 PR #470 PR #527 #572 PR #585 PR #603 #614
%% PBI: #460 PR #470 PR #527 #572 PR #585 PR #603 #614 #627
T3C --> PBI
BST["Settled thread browser cleanup"]
%% BST: #601
Expand Down
3 changes: 2 additions & 1 deletion docs/user/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ desktop tabs.

On an agent tab, choose **Take control** before interacting with the page.
The agent pauses its actions while you control it. Choose **Release control**
to let the agent continue.
to let the agent continue. A disconnected viewer does not return control to the
agent. After reconnecting, take control again to continue or release it.

You can also open a tab yourself and tell the agent to “look at this”. Agents
can read your tab, but cannot use the rbw login tool there when it is a native
Expand Down
Loading