Skip to content

Commit d4d16a0

Browse files
authored
feat(desktop): keep the machine awake for background chats and yield the page to the user (#8671)
* feat(desktop): keep the machine awake for background chats and yield the page to the user - Prevent sleep: while any chat has desktop work claimed on this machine, the app holds a prevent-app-suspension power blocker, released as soon as the last result is delivered. On by default, with a switch in Desktop settings that notes a closed lid still sleeps the computer. The switch is a new optional bridge method, so older shells simply do not show it. - Takeover: when the user clicks, scrolls or types in the page an agent drives, that chat's next browser action waits, marked as needing attention, until the user has left the page alone for a few seconds, then resumes in the same page. A Stop still cancels it while it waits. * fix(desktop): give the page back to the user mid-action - A browser action now rechecks, right before each input it sends, whether the user is working in the page. Between a batch's steps or during a click's target probes, the user wins and the action stops before its next input. - The wait for the user is bounded inside the action's own deadline. A user who keeps working gets the model a clear "not run" instead of a timeout with an unknown outcome. - The user's activity belongs to the tab they acted in. Once the agent drives another tab (the old one closed or replaced, the session reopened), that activity no longer holds anything up. - `createSleepBlocker` is documented. * test(desktop): read the blockers the OS holds instead of asserting the mock was called * fix(desktop): yield the page only for background calls that act on it - Only a call from the background executor yields the page to the user. A call from the chat view the user is watching acts exactly as before: there the user steers the agent directly. - A background call that only reads the page (snapshot, read text, list tabs) never waits for the user, since it cannot collide with their input. - Which browser tools only observe the page now lives in @sim/browser-protocol, shared by the chat view's replay policy and the driver. - The prevent-sleep switch shows only when Sim runs chats on this device in the background. - Tests: a chat-view call acts at once under user input; a background call waits, and a background read does not; a user who starts working mid-action stops it before its next input; sign-out releases the sleep blocker while a call is still running. * test(desktop): give the notification tests the prevent-sleep setter the settings service now requires * test(desktop): show the prevent-sleep switch only on a device that runs chats in the background
1 parent 0d74834 commit d4d16a0

20 files changed

Lines changed: 693 additions & 44 deletions

File tree

‎apps/desktop/e2e/background-executor.spec.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@ async function launch(userData: string): Promise<{ app: ElectronApplication; win
359359
SIM_DESKTOP_USER_DATA: userData,
360360
},
361361
})
362+
// A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own handling
363+
// decides their outcome exactly as it does in production.
364+
const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {})
365+
app.context().pages().forEach(leaveDialogsToDesktop)
366+
app.context().on('page', leaveDialogsToDesktop)
362367
const window = await app.firstWindow()
363368
return { app, window }
364369
}
@@ -730,6 +735,116 @@ test.describe('background executor', () => {
730735
expect(device).toEqual({ deviceId, protocolVersion: 1 })
731736
})
732737
})
738+
739+
test('I: the agent yields its page while the user works in it, then takes it back', async () => {
740+
app = (await launch(mkdtempSync(join(tmpdir(), 'sim-executor-i-')))).app
741+
const deviceId = await registeredDevice()
742+
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
743+
url: `${sim.origin}/counter?chat=I`,
744+
})
745+
const outline = ((await settled(opened)).data?.snapshot as { outline: string }).outline
746+
const button = refFor(outline, 'Count visit')
747+
748+
const typeInAgentPage = () =>
749+
app?.evaluate(({ webContents }) => {
750+
const page = webContents
751+
.getAllWebContents()
752+
.find((contents) => contents.getURL().includes('/counter?chat=I'))
753+
page?.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
754+
page?.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
755+
})
756+
await typeInAgentPage()
757+
const click = sim.issue(deviceId, CHAT_A, 'browser_click', { elementId: button })
758+
for (let i = 0; i < 4; i++) {
759+
await sleep(500)
760+
await typeInAgentPage()
761+
}
762+
const lastUserInputAt = Date.now()
763+
764+
await check('I: the click waits until the user stops, then runs once', async () => {
765+
const completion = await settled(click, 30_000)
766+
expect(completion.status, completion.message).toBe('success')
767+
expect(completion.at - lastUserInputAt).toBeGreaterThanOrEqual(3_000)
768+
await expect.poll(() => sim.hits.get('I')).toBe(1)
769+
})
770+
})
771+
772+
test('I: an action the user never stops working long enough for does not run', async () => {
773+
app = (await launch(mkdtempSync(join(tmpdir(), 'sim-executor-i2-')))).app
774+
const deviceId = await registeredDevice()
775+
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
776+
url: `${sim.origin}/counter?chat=I2`,
777+
})
778+
const outline = ((await settled(opened)).data?.snapshot as { outline: string }).outline
779+
const button = refFor(outline, 'Count visit')
780+
const typeInAgentPage = () =>
781+
app?.evaluate(({ webContents }) => {
782+
const page = webContents
783+
.getAllWebContents()
784+
.find((contents) => contents.getURL().includes('/counter?chat=I2'))
785+
page?.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
786+
page?.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
787+
})
788+
789+
await typeInAgentPage()
790+
const click = sim.issue(deviceId, CHAT_A, 'browser_click', { elementId: button })
791+
let typing = true
792+
const keepTyping = (async () => {
793+
while (typing) {
794+
await typeInAgentPage()
795+
await sleep(1_000)
796+
}
797+
})()
798+
799+
await check('I: the click reports it never ran, instead of timing out', async () => {
800+
const completion = await settled(click, 60_000)
801+
typing = false
802+
await keepTyping
803+
expect(completion.status).toBe('error')
804+
expect(completion.message).toContain('Not run: the user kept working in this page')
805+
expect(completion.data).not.toMatchObject({ outcomeUnknown: true })
806+
expect(sim.hits.get('I2') ?? 0).toBe(0)
807+
})
808+
})
809+
810+
test('J: the machine stays awake only while a chat has work running', async () => {
811+
app = (await launch(mkdtempSync(join(tmpdir(), 'sim-executor-j-')))).app
812+
await app.evaluate(({ powerSaveBlocker }) => {
813+
const log: string[] = []
814+
const active = new Set<number>()
815+
let next = 1
816+
const target = globalThis as typeof globalThis & { __sleepBlocks?: string[] }
817+
target.__sleepBlocks = log
818+
powerSaveBlocker.start = (type) => {
819+
log.push(`start:${type}`)
820+
active.add(next)
821+
return next++
822+
}
823+
powerSaveBlocker.stop = (id) => {
824+
log.push('stop')
825+
active.delete(id)
826+
return true
827+
}
828+
powerSaveBlocker.isStarted = (id) => active.has(id)
829+
})
830+
const deviceId = await registeredDevice()
831+
const sleepLog = () =>
832+
app?.evaluate(() => (globalThis as { __sleepBlocks?: string[] }).__sleepBlocks ?? [])
833+
834+
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
835+
operation: 'run',
836+
args: { command: 'sleep 3; echo awake', waitSeconds: 30 },
837+
})
838+
await check('J: a blocker is held while the command runs', async () => {
839+
await expect.poll(sleepLog, { timeout: 15_000 }).toEqual(['start:prevent-app-suspension'])
840+
})
841+
await check('J: it is released once the result is delivered', async () => {
842+
await settled(run)
843+
await expect
844+
.poll(sleepLog, { timeout: 10_000 })
845+
.toEqual(['start:prevent-app-suspension', 'stop'])
846+
})
847+
})
733848
})
734849

735850
function readFileSafe(path: string): string {

‎apps/desktop/src/main/browser-agent/driver.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,102 @@ describe('executeTool', () => {
7979
driver = freshDriver()
8080
})
8181

82+
/** The user presses a key in the page the agent drives, as the browser reports it. */
83+
function typeInAgentPage(contents: WebContents) {
84+
const calls: ReadonlyArray<readonly unknown[]> = vi.mocked(contents.on).mock.calls
85+
const listener = calls.find(([event]) => event === 'before-input-event')?.[1]
86+
if (typeof listener !== 'function') throw new Error('No input listener on the agent page')
87+
listener({}, { type: 'keyDown', isAutoRepeat: false })
88+
}
89+
90+
it('lets a call from the chat view act at once while the user works in the page', async () => {
91+
await driver.executeTool('chat-test', 'browser_open_tab', {})
92+
typeInAgentPage(session.requireTab().view.webContents)
93+
expect(session.msSinceUserIntervention()).not.toBeNull()
94+
vi.useFakeTimers()
95+
try {
96+
let settled = false
97+
const opening = driver.executeTool('chat-test', 'browser_open_tab', {}).then((result) => {
98+
settled = true
99+
return result
100+
})
101+
await vi.advanceTimersByTimeAsync(100)
102+
103+
expect(settled).toBe(true)
104+
expect((await opening).ok).toBe(true)
105+
} finally {
106+
vi.useRealTimers()
107+
}
108+
})
109+
110+
it('makes a background call wait until the user leaves the page alone', async () => {
111+
await driver.executeTool('chat-test', 'browser_open_tab', {})
112+
typeInAgentPage(session.requireTab().view.webContents)
113+
vi.useFakeTimers()
114+
try {
115+
let settled = false
116+
const opening = driver
117+
.executeTool('chat-test', 'browser_open_tab', {}, 'background-1', undefined, {
118+
background: true,
119+
})
120+
.then((result) => {
121+
settled = true
122+
return result
123+
})
124+
await vi.advanceTimersByTimeAsync(1_000)
125+
expect(settled).toBe(false)
126+
127+
await vi.advanceTimersByTimeAsync(4_000)
128+
expect((await opening).ok).toBe(true)
129+
} finally {
130+
vi.useRealTimers()
131+
}
132+
})
133+
134+
it('stops a background action before its next input once the user starts working mid-action', async () => {
135+
await driver.executeTool('chat-test', 'browser_open_tab', {})
136+
const contents = session.requireTab().view.webContents
137+
// The user presses a key while the action reads the page before acting on it.
138+
const probe = vi.mocked(contents.executeJavaScript).getMockImplementation()
139+
vi.mocked(contents.executeJavaScript).mockImplementation(async (...args) => {
140+
typeInAgentPage(contents)
141+
return probe ? probe(...args) : undefined
142+
})
143+
144+
const result = await driver.executeTool(
145+
'chat-test',
146+
'browser_press_key',
147+
{ key: 'Enter' },
148+
'background-key',
149+
undefined,
150+
{ background: true }
151+
)
152+
153+
expect(result.ok).toBe(false)
154+
expect(result.error).toContain('the user started working in this page')
155+
})
156+
157+
it('never makes a background read wait for the user', async () => {
158+
await driver.executeTool('chat-test', 'browser_open_tab', {})
159+
typeInAgentPage(session.requireTab().view.webContents)
160+
vi.useFakeTimers()
161+
try {
162+
let settled = false
163+
void driver
164+
.executeTool('chat-test', 'browser_list_tabs', {}, 'background-read', undefined, {
165+
background: true,
166+
})
167+
.then(() => {
168+
settled = true
169+
})
170+
await vi.advanceTimersByTimeAsync(100)
171+
172+
expect(settled).toBe(true)
173+
} finally {
174+
vi.useRealTimers()
175+
}
176+
})
177+
82178
it('returns ok:false instead of throwing for tool-level failures', async () => {
83179
// No session exists, so any page-dependent tool fails with guidance.
84180
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 1 })

‎apps/desktop/src/main/browser-agent/driver.ts‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import {
2020
BROWSER_DATA_KINDS,
2121
BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS,
22+
BROWSER_TOOL_OBSERVES_ONLY,
2223
BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS,
2324
BROWSER_UPLOAD_MAX_FILES,
2425
type BrowserDataKind,
@@ -32,7 +33,7 @@ import {
3233
import type { BrowserDownloadsState, BrowserToolbarCommand } from '@sim/desktop-bridge'
3334
import { createLogger } from '@sim/logger'
3435
import { getErrorMessage } from '@sim/utils/errors'
35-
import { sleep } from '@sim/utils/helpers'
36+
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
3637
import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object'
3738
import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron'
3839
import { Menu } from 'electron'
@@ -5094,12 +5095,64 @@ function withNotices(result: unknown): unknown {
50945095
return { value: result, notices }
50955096
}
50965097

5098+
/** Quiet time after the user's last click or keystroke before the agent takes the page back. */
5099+
const USER_TAKEOVER_IDLE_MS = 4_000
5100+
/** The longest an action waits for the user to finish; past it, the action does not run. */
5101+
const USER_TAKEOVER_MAX_WAIT_MS = 30_000
5102+
const USER_KEPT_WORKING =
5103+
'Not run: the user kept working in this page, so this browser action never started and nothing was sent to the page. Ask the user whether they are done before acting in this page again.'
5104+
const USER_TOOK_OVER_MID_ACTION =
5105+
'Stopped: the user started working in this page, so this browser action stopped before sending its next input. Earlier steps may have taken effect; inspect the page before continuing.'
5106+
5107+
function isUserWorkingInPage(): boolean {
5108+
const since = session.msSinceUserIntervention()
5109+
return since !== null && since < USER_TAKEOVER_IDLE_MS
5110+
}
5111+
5112+
/**
5113+
* The user and the agent never act in the same page at once. While the user is clicking, typing
5114+
* or scrolling in the tab the agent drives, the agent's next action waits, marked as needing
5115+
* attention, and resumes once the user has left the page alone for a few seconds.
5116+
*/
5117+
async function yieldToUser(
5118+
toolCallId: string | undefined,
5119+
signal: AbortSignal,
5120+
maxWaitMs: number
5121+
): Promise<void> {
5122+
session.setAutomationNeedsAttention(true)
5123+
logger.info('Browser automation yielding to the user', { toolCallId })
5124+
// Bounded well inside the action's own deadline, so a user who keeps working gets the model a
5125+
// clear "not run" rather than a timeout with an unknown outcome.
5126+
const deadline = Date.now() + maxWaitMs
5127+
try {
5128+
while (isUserWorkingInPage()) {
5129+
if (Date.now() >= deadline) throw new ToolError(USER_KEPT_WORKING)
5130+
await interruptibleSleep(250, signal)
5131+
if (signal.aborted) throw new ToolError('This browser action was cancelled.')
5132+
}
5133+
} finally {
5134+
session.setAutomationNeedsAttention(false)
5135+
}
5136+
logger.info('Browser automation resumed after the user stopped', { toolCallId })
5137+
}
5138+
5139+
/** How a browser call reached the driver. */
5140+
interface BrowserToolExecutionOptions {
5141+
/**
5142+
* Run by the background executor while the user may be working in the same page, so it yields
5143+
* the page to them. A call from the chat view the user is watching never does: there the user
5144+
* steers the agent directly, as they always have.
5145+
*/
5146+
background?: boolean
5147+
}
5148+
50975149
export async function executeTool(
50985150
scopeId: string,
50995151
tool: BrowserToolName,
51005152
params: Record<string, unknown>,
51015153
toolCallId?: string,
5102-
authorizationBoundary?: BrowserToolQueueBoundary
5154+
authorizationBoundary?: BrowserToolQueueBoundary,
5155+
options: BrowserToolExecutionOptions = {}
51035156
): Promise<{ ok: boolean; result?: unknown; error?: string }> {
51045157
const resolvedScopeId = resolveDriverScopeId(scopeId)
51055158
if (authorizationBoundary) {
@@ -5190,17 +5243,36 @@ export async function executeTool(
51905243
session.setAutomationActive(true)
51915244
}
51925245
try {
5246+
// Only a background call acting on the page yields; reading it cannot collide with the user.
5247+
const yieldsToUser =
5248+
options.background === true &&
5249+
tool !== 'browser_request_takeover' &&
5250+
!BROWSER_TOOL_OBSERVES_ONLY[tool]
5251+
const watchdogMs = browserToolWatchdogMs(tool, params)
5252+
if (yieldsToUser && isUserWorkingInPage()) {
5253+
await yieldToUser(
5254+
toolCallId,
5255+
executionController.signal,
5256+
watchdogMs === null
5257+
? USER_TAKEOVER_MAX_WAIT_MS
5258+
: Math.min(USER_TAKEOVER_MAX_WAIT_MS, Math.floor(watchdogMs / 2))
5259+
)
5260+
}
51935261
const response = dialogResponse(tool, params)
51945262
state.dialogResponse = response
51955263
? { contents: session.requireAutomationTab().view.webContents, response }
51965264
: null
51975265
const executionEpoch = ++state.toolExecutionEpoch
5198-
const watchdogMs = browserToolWatchdogMs(tool, params)
51995266
const executionDeadline = watchdogMs === null ? undefined : Date.now() + watchdogMs
5267+
// Checked right before each input the action sends: a user who starts working in the
5268+
// page mid-action (between a batch's steps, during a click's target probes) wins it.
52005269
const assertCurrentExecution = () => {
52015270
if (state.toolExecutionEpoch !== executionEpoch) {
52025271
throw new ToolError('This browser action expired before it could dispatch input.')
52035272
}
5273+
if (yieldsToUser && isUserWorkingInPage()) {
5274+
throw new ToolError(USER_TOOK_OVER_MID_ACTION)
5275+
}
52045276
}
52055277
let actionOutcome: BrowserActionOutcome | undefined
52065278
const execution = withPostActionObservation(

0 commit comments

Comments
 (0)