diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b961b1d132..3c74152963 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -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"; @@ -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(); @@ -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({ + 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({ + scope, + tabId, + operation: "watchStart", + input: { subscription: "(emit) => emit(1)" }, + }) + .pipe(Effect.flip); + expect(refused).toMatchObject({ reason: "humanControl" }); + const after = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(after.control).toEqual(before.control); } expect(realm.cleanups).toBe(1); const callbacks = realm.callbacks; @@ -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({ + 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({ + 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({ + scope, + tabId, + operation: "status", + input: {}, + }); + yield* broker.invoke({ scope, tabId, operation: "snapshot", input: {} }); + const after = yield* broker.invoke({ + 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* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index c1b7392a3b..0f8bef9a1f 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -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), }); @@ -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) diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index a0f6580528..b381bc48c7 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -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(); @@ -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 () => { diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index 417e88f039..050bfe786c 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -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( @@ -17,7 +19,7 @@ export class SessionControl { private readonly onGenerationChange: () => void; private tail: Promise = Promise.resolve(); private pending: Promise = Promise.resolve(); - private owner: string | null = null; + private owner: string | typeof DISCONNECTED_HUMAN | null = null; private epoch = 0; private closed = false; private readonly inputs = { @@ -35,6 +37,10 @@ export class SessionControl { return this.owner; } + get humanDisconnected() { + return this.owner === DISCONNECTED_HUMAN; + } + get generation() { return this.epoch; } @@ -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(); @@ -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; @@ -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; } } @@ -183,7 +191,13 @@ export class SessionControl { } async disconnect(viewerId: string, afterDrain?: () => Promise) { - 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() { diff --git a/docs/map.md b/docs/map.md index ef3e12609b..bbac6c80c2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 diff --git a/docs/user/browser.md b/docs/user/browser.md index c8cf97ccc5..3e9b1cf1cc 100644 --- a/docs/user/browser.md +++ b/docs/user/browser.md @@ -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