Skip to content

Commit cec8d73

Browse files
fix(server): a background agent tab survives a desktop window reload (#33666)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 65ffcbf commit cec8d73

3 files changed

Lines changed: 124 additions & 5 deletions

File tree

‎apps/server/src/preview/Manager.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export class PreviewManager extends Context.Service<
4646
readonly markHeadless: (
4747
input: PreviewCloseInput & { readonly tabId: string },
4848
) => Effect.Effect<void, PreviewError>;
49+
/** Publishes the tab's unchanged snapshot again, for clients that dropped the tab. */
50+
readonly announce: (
51+
input: PreviewCloseInput & { readonly tabId: string },
52+
) => Effect.Effect<void, PreviewError>;
4953
readonly requestReveal: (
5054
input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean },
5155
) => Effect.Effect<void, PreviewError>;
@@ -298,6 +302,28 @@ export const make = Effect.gen(function* PreviewManagerMake() {
298302
);
299303
});
300304

305+
const announce: PreviewManager["Service"]["announce"] = Effect.fn("PreviewManager.announce")(
306+
function* (input) {
307+
return yield* mutateExistingSession(
308+
input.threadId,
309+
input.tabId,
310+
Effect.fn("PreviewManager.announceSession")(function* (session) {
311+
return {
312+
next: session,
313+
emit: {
314+
type: "resized",
315+
threadId: session.threadId,
316+
tabId: session.tabId,
317+
createdAt: yield* currentIsoTimestamp,
318+
snapshot: session.snapshot,
319+
},
320+
result: undefined,
321+
};
322+
}),
323+
);
324+
},
325+
);
326+
301327
const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")(
302328
function* (input) {
303329
return yield* mutateExistingSession(
@@ -452,6 +478,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
452478
requestReveal,
453479
reportStatus,
454480
markHeadless,
481+
announce,
455482
resize,
456483
adjust,
457484
close,

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ const desktopRenders = (tabId: string) => {
321321
}
322322
return desktopTabs.has(tabId);
323323
};
324+
/** When set, a desktop that has not attached a tab waits for it the way the real channel does. */
325+
let desktopAttachesLate = false;
326+
let desktopAttachWaiting = Promise.withResolvers<void>();
324327
const shownDesktopTabs = new Set<string>();
325328
const releasedDesktopTabs: Array<string> = [];
326329
const desktopTurns = new NodeEvents.EventEmitter();
@@ -355,7 +358,30 @@ const dependencies = Layer.mergeAll(
355358
}),
356359
Layer.succeed(DesktopChannel.DesktopBrowserChannel, {
357360
available: true,
358-
awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
361+
awaitAttached: (key, timeout) =>
362+
!desktopAttachesLate || desktopRenders(key.tabId)
363+
? Effect.sync(() => desktopRenders(key.tabId))
364+
: Effect.raceFirst(
365+
Stream.callback<string>((queue) =>
366+
Effect.acquireRelease(
367+
Effect.sync(() => {
368+
const onAttach = (attached: { tabId: string }) =>
369+
Queue.offerUnsafe(queue, attached.tabId);
370+
desktopDetaches.on("attach", onAttach);
371+
return onAttach;
372+
}),
373+
(onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)),
374+
),
375+
).pipe(
376+
Stream.filter((tabId) => tabId === key.tabId),
377+
Stream.runHead,
378+
Effect.as(true),
379+
),
380+
Effect.sync(() => desktopAttachWaiting.resolve()).pipe(
381+
Effect.andThen(Effect.sleep(timeout)),
382+
Effect.as(false),
383+
),
384+
),
359385
detached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
360386
Effect.acquireRelease(
361387
Effect.sync(() => {
@@ -460,6 +486,8 @@ beforeEach(() => {
460486
desktopTabs.clear();
461487
shownDesktopTabs.clear();
462488
desktopRendersNext = false;
489+
desktopAttachesLate = false;
490+
desktopAttachWaiting = Promise.withResolvers<void>();
463491
desktopPageUrl = "about:blank";
464492
desktopSizeAnswer = null;
465493
desktopDetachReady = Promise.withResolvers<void>();
@@ -627,6 +655,63 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st
627655
).pipe(Effect.provide(layer)),
628656
);
629657

658+
it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop", () =>
659+
Effect.scoped(
660+
Effect.gen(function* () {
661+
const { broker } = yield* ready;
662+
const manager = yield* Manager.PreviewManager;
663+
const threadId = scope.thread.threadId;
664+
desktopRendersNext = true;
665+
const opened = yield* broker.invoke<PreviewAutomationStatus>({
666+
scope,
667+
operation: "open",
668+
input: { url: "https://example.com/?t=1", reuseExistingTab: false, open: false },
669+
});
670+
const tabId = PreviewTabId.make(opened.tabId!);
671+
// The renderer reloads on another thread: its webview goes, and it only mounts this
672+
// thread's tabs again once a preview event for the thread reaches it.
673+
yield* Effect.promise(() => desktopDetachReady.promise);
674+
desktopAttachesLate = true;
675+
desktopTabs.delete(tabId);
676+
desktopDetaches.emit("detach", { threadId, tabId });
677+
yield* Effect.promise(() => desktopReleased.promise);
678+
// It is still reloading when the agent's call arrives, so it misses the first event.
679+
yield* Effect.gen(function* () {
680+
yield* Effect.promise(() => desktopAttachWaiting.promise);
681+
const events = yield* manager.subscribeEvents;
682+
const nextForThread = Effect.gen(function* () {
683+
let event = yield* PubSub.take(events);
684+
while (event.threadId !== threadId) event = yield* PubSub.take(events);
685+
});
686+
yield* nextForThread;
687+
yield* nextForThread;
688+
desktopTabs.add(tabId);
689+
desktopDetaches.emit("attach", { threadId, tabId });
690+
}).pipe(Effect.forkScoped);
691+
692+
const evaluating = yield* broker
693+
.invoke({
694+
scope,
695+
tabId,
696+
operation: "evaluate",
697+
input: { expression: "navigator.userAgent" },
698+
})
699+
.pipe(Effect.forkScoped);
700+
yield* Effect.raceFirst(
701+
Fiber.join(evaluating),
702+
Effect.promise(() => desktopAttachWaiting.promise).pipe(
703+
Effect.andThen(TestClock.adjust("10 seconds")),
704+
Effect.andThen(Fiber.join(evaluating)),
705+
),
706+
);
707+
const { sessions } = yield* manager.list({ threadId });
708+
709+
expect(sessions.find((session) => session.tabId === tabId)?.headless).toBeUndefined();
710+
expect(desktopConnections).toHaveLength(2);
711+
}),
712+
).pipe(Effect.provide(layer)),
713+
);
714+
630715
it.effect("a desktop page that loaded before the server attached reports its loaded title", () =>
631716
Effect.scoped(
632717
Effect.gen(function* () {

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -766,10 +766,17 @@ const make = Effect.gen(function* () {
766766
const desktopRenders = (snapshot: PreviewSessionSnapshot) =>
767767
desktopChannel.available && !snapshot.headless
768768
? runPromise(
769-
desktopChannel.awaitAttached(
770-
{ threadId: snapshot.threadId, tabId: snapshot.tabId },
771-
DESKTOP_ATTACH_TIMEOUT,
772-
),
769+
Effect.gen(function* () {
770+
const key = { threadId: snapshot.threadId, tabId: snapshot.tabId };
771+
// A reloaded desktop mounts a thread's tabs again only once an event for it arrives,
772+
// and a renderer still reloading misses events, so repeat it until the wait ends.
773+
if (!(yield* desktopChannel.isAttached(key))) {
774+
yield* manager
775+
.announce({ threadId: ThreadId.make(key.threadId), tabId: key.tabId })
776+
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced("1 second")), Effect.forkScoped);
777+
}
778+
return yield* desktopChannel.awaitAttached(key, DESKTOP_ATTACH_TIMEOUT);
779+
}).pipe(Effect.scoped),
773780
)
774781
: Promise.resolve(false);
775782

0 commit comments

Comments
 (0)