Skip to content

Commit d3f9599

Browse files
committed
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.
1 parent f6d9127 commit d3f9599

14 files changed

Lines changed: 307 additions & 1 deletion

File tree

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

Lines changed: 77 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,78 @@ 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('J: the machine stays awake only while a chat has work running', async () => {
773+
app = (await launch(mkdtempSync(join(tmpdir(), 'sim-executor-j-')))).app
774+
await app.evaluate(({ powerSaveBlocker }) => {
775+
const log: string[] = []
776+
const active = new Set<number>()
777+
let next = 1
778+
const target = globalThis as typeof globalThis & { __sleepBlocks?: string[] }
779+
target.__sleepBlocks = log
780+
powerSaveBlocker.start = (type) => {
781+
log.push(`start:${type}`)
782+
active.add(next)
783+
return next++
784+
}
785+
powerSaveBlocker.stop = (id) => {
786+
log.push('stop')
787+
active.delete(id)
788+
return true
789+
}
790+
powerSaveBlocker.isStarted = (id) => active.has(id)
791+
})
792+
const deviceId = await registeredDevice()
793+
const sleepLog = () =>
794+
app?.evaluate(() => (globalThis as { __sleepBlocks?: string[] }).__sleepBlocks ?? [])
795+
796+
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
797+
operation: 'run',
798+
args: { command: 'sleep 3; echo awake', waitSeconds: 30 },
799+
})
800+
await check('J: a blocker is held while the command runs', async () => {
801+
await expect.poll(sleepLog, { timeout: 15_000 }).toEqual(['start:prevent-app-suspension'])
802+
})
803+
await check('J: it is released once the result is delivered', async () => {
804+
await settled(run)
805+
await expect
806+
.poll(sleepLog, { timeout: 10_000 })
807+
.toEqual(['start:prevent-app-suspension', 'stop'])
808+
})
809+
})
733810
})
734811

735812
function readFileSafe(path: string): string {

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
import type { BrowserDownloadsState, BrowserToolbarCommand } from '@sim/desktop-bridge'
3333
import { createLogger } from '@sim/logger'
3434
import { getErrorMessage } from '@sim/utils/errors'
35-
import { sleep } from '@sim/utils/helpers'
35+
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
3636
import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object'
3737
import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron'
3838
import { Menu } from 'electron'
@@ -5094,6 +5094,30 @@ function withNotices(result: unknown): unknown {
50945094
return { value: result, notices }
50955095
}
50965096

5097+
/** Quiet time after the user's last click or keystroke before the agent takes the page back. */
5098+
const USER_TAKEOVER_IDLE_MS = 4_000
5099+
5100+
function isUserWorkingInPage(): boolean {
5101+
const since = session.msSinceUserIntervention()
5102+
return since !== null && since < USER_TAKEOVER_IDLE_MS
5103+
}
5104+
5105+
/**
5106+
* The user and the agent never act in the same page at once. While the user is clicking, typing
5107+
* or scrolling in the tab the agent drives, the agent's next action waits, marked as needing
5108+
* attention, and resumes once the user has left the page alone for a few seconds.
5109+
*/
5110+
async function yieldToUser(toolCallId: string | undefined, signal: AbortSignal): Promise<void> {
5111+
session.setAutomationNeedsAttention(true)
5112+
logger.info('Browser automation yielding to the user', { toolCallId })
5113+
while (isUserWorkingInPage()) {
5114+
await interruptibleSleep(250, signal)
5115+
if (signal.aborted) throw new ToolError('This browser action was cancelled.')
5116+
}
5117+
session.setAutomationNeedsAttention(false)
5118+
logger.info('Browser automation resumed after the user stopped', { toolCallId })
5119+
}
5120+
50975121
export async function executeTool(
50985122
scopeId: string,
50995123
tool: BrowserToolName,
@@ -5190,6 +5214,9 @@ export async function executeTool(
51905214
session.setAutomationActive(true)
51915215
}
51925216
try {
5217+
if (tool !== 'browser_request_takeover' && isUserWorkingInPage()) {
5218+
await yieldToUser(toolCallId, executionController.signal)
5219+
}
51935220
const response = dialogResponse(tool, params)
51945221
state.dialogResponse = response
51955222
? { contents: session.requireAutomationTab().view.webContents, response }

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ interface BrowserScopeState {
298298
*/
299299
findingTabId: string | null
300300
findingRequestId: number | null
301+
/** When the user last clicked, scrolled or typed in the tab the agent drives. */
302+
userInterventionAt: number | null
301303
}
302304

303305
function createBrowserScopeState(): BrowserScopeState {
@@ -319,6 +321,7 @@ function createBrowserScopeState(): BrowserScopeState {
319321
automationNeedsAttention: false,
320322
findingTabId: null,
321323
findingRequestId: null,
324+
userInterventionAt: null,
322325
}
323326
}
324327

@@ -2368,6 +2371,7 @@ function initializeTabView(
23682371
return
23692372
}
23702373
const tab = tabs.find((entry) => entry.view.webContents === contents)
2374+
if (tab?.id === currentScope.automationTabId) noteUserIntervention()
23712375
if (tab?.id === currentScope.activeTabId) {
23722376
currentScope.visibleTabUserSelected = true
23732377
if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now()
@@ -2484,6 +2488,13 @@ function initializeTabView(
24842488
'before-input-event',
24852489
bindToBrowserScope(scopeId, (event, input) => {
24862490
const tab = tabs.find((entry) => entry.view === view)
2491+
if (
2492+
!isDispatchingAgentInput(contents) &&
2493+
input.type === 'keyDown' &&
2494+
tab?.id === currentScope.automationTabId
2495+
) {
2496+
noteUserIntervention()
2497+
}
24872498
if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) {
24882499
currentScope.visibleTabUserSelected = true
24892500
if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now()
@@ -3117,8 +3128,20 @@ export function claimActiveTabForUser(): AgentTab | null {
31173128
return tab
31183129
}
31193130

3131+
/** The user just acted in the page the agent drives; the agent yields until they stop. */
3132+
function noteUserIntervention(): void {
3133+
currentScope.userInterventionAt = Date.now()
3134+
}
3135+
3136+
/** How long ago the user last acted in the current scope's agent tab, or null if never. */
3137+
export function msSinceUserIntervention(): number | null {
3138+
const at = currentScope.userInterventionAt
3139+
return at === null ? null : Math.max(0, Date.now() - at)
3140+
}
3141+
31203142
/** Explicit hand-back after takeover lets automation resume in the same page. */
31213143
export function returnAutomationTabToAgent(): void {
3144+
currentScope.userInterventionAt = null
31223145
if (currentScope.activeTabId === currentScope.automationTabId) {
31233146
currentScope.visibleTabUserSelected = false
31243147
}

‎apps/desktop/src/main/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export interface DesktopSettings {
104104
/** Whether omnibox typing may request live Google search completions. */
105105
browserSearchSuggestionsEnabled?: boolean
106106
terminalEnabled?: boolean
107+
/** Keep the machine awake while a chat is running desktop work in the background. */
108+
preventSleepWhileRunning?: boolean
107109
/** Device-wide browser page appearance; `app` follows Sim. */
108110
browserTheme?: 'app' | 'light' | 'dark'
109111
/** Device-wide default zoom for built-in browser pages. */
@@ -236,6 +238,7 @@ const DEFAULT_SETTINGS: DesktopSettings = {
236238
browserEnabled: true,
237239
browserSearchSuggestionsEnabled: true,
238240
terminalEnabled: true,
241+
preventSleepWhileRunning: true,
239242
}
240243

241244
export interface ConfigStore {

‎apps/desktop/src/main/desktop-executor/service.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export interface DesktopExecutorServiceDeps {
5252
accountDataAvailable: () => boolean
5353
runner: DesktopToolRunner
5454
onApprovals?: (items: DesktopApprovalItem[]) => void
55+
/** Whether any chat has desktop work claimed on this machine changed. */
56+
onBusyChange?: (busy: boolean) => void
5557
}
5658

5759
export interface DesktopExecutorService {
@@ -183,6 +185,7 @@ export function createDesktopExecutorService(
183185
leaseRenewMs: nextTiming.leaseRenewMs,
184186
onUnregistered: handleUnrecognized,
185187
...(deps.onApprovals ? { onApprovals: deps.onApprovals } : {}),
188+
...(deps.onBusyChange ? { onBusyChange: deps.onBusyChange } : {}),
186189
})
187190
await executor.recover()
188191
// Signed out while recovering: sign-out already disposed this executor.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { createSleepBlocker } from '@/main/desktop-executor/sleep-blocker'
3+
4+
function harness(enabled = true) {
5+
let nextId = 1
6+
const active = new Set<number>()
7+
const powerSaveBlocker = {
8+
start: vi.fn(() => {
9+
const id = nextId++
10+
active.add(id)
11+
return id
12+
}),
13+
stop: vi.fn((id: number) => {
14+
active.delete(id)
15+
}),
16+
isStarted: (id: number) => active.has(id),
17+
}
18+
let preference = enabled
19+
const blocker = createSleepBlocker({ enabled: () => preference, powerSaveBlocker })
20+
return {
21+
blocker,
22+
powerSaveBlocker,
23+
active,
24+
setPreference: (value: boolean) => {
25+
preference = value
26+
blocker.refresh()
27+
},
28+
}
29+
}
30+
31+
describe('keeping the machine awake for background work', () => {
32+
it('holds one blocker only while a chat has work running', () => {
33+
const { blocker, powerSaveBlocker, active } = harness()
34+
35+
blocker.setBusy(true)
36+
blocker.setBusy(true)
37+
expect(active.size).toBe(1)
38+
expect(powerSaveBlocker.start).toHaveBeenCalledWith('prevent-app-suspension')
39+
40+
blocker.setBusy(false)
41+
expect(active.size).toBe(0)
42+
})
43+
44+
it('never holds one while the user has switched it off, and follows the switch live', () => {
45+
const { blocker, active, setPreference } = harness(false)
46+
47+
blocker.setBusy(true)
48+
expect(active.size).toBe(0)
49+
50+
setPreference(true)
51+
expect(active.size).toBe(1)
52+
setPreference(false)
53+
expect(active.size).toBe(0)
54+
})
55+
56+
it('does not start one for an idle machine when the switch turns on', () => {
57+
const { active, setPreference } = harness(false)
58+
59+
setPreference(true)
60+
61+
expect(active.size).toBe(0)
62+
})
63+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Keeps the machine from idle-sleeping while a chat has desktop work running in the background,
3+
* and only then. It cannot keep a closed laptop awake: the OS sleeps it anyway, and the work
4+
* resumes reporting when it wakes.
5+
*/
6+
7+
interface PowerSaveBlocker {
8+
start(type: 'prevent-app-suspension'): number
9+
stop(id: number): void
10+
isStarted(id: number): boolean
11+
}
12+
13+
interface SleepBlockerDeps {
14+
/** The user's "prevent sleep while a chat is running" switch. */
15+
enabled: () => boolean
16+
powerSaveBlocker: PowerSaveBlocker
17+
}
18+
19+
export function createSleepBlocker(deps: SleepBlockerDeps) {
20+
let busy = false
21+
let blockerId: number | null = null
22+
23+
const apply = () => {
24+
const wanted = busy && deps.enabled()
25+
const held = blockerId !== null && deps.powerSaveBlocker.isStarted(blockerId)
26+
if (wanted && !held) {
27+
blockerId = deps.powerSaveBlocker.start('prevent-app-suspension')
28+
} else if (!wanted && blockerId !== null) {
29+
if (held) deps.powerSaveBlocker.stop(blockerId)
30+
blockerId = null
31+
}
32+
}
33+
34+
return {
35+
/** Whether any chat has desktop work claimed on this machine. */
36+
setBusy(next: boolean): void {
37+
busy = next
38+
apply()
39+
},
40+
/** Re-applies the user's switch to the work running now. */
41+
refresh(): void {
42+
apply()
43+
},
44+
}
45+
}

‎apps/desktop/src/main/desktop-settings.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface DesktopSettingsService {
3737
getPreferences(): DesktopPreferences
3838
setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences
3939
setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences
40+
setPreventSleepWhileRunning(enabled: boolean): DesktopPreferences
4041
setAppearancePreference(
4142
key: DesktopAppearanceSettingKey,
4243
value: DesktopAppearanceTheme
@@ -60,6 +61,8 @@ interface DesktopSettingsServiceDeps {
6061
setBrowserEnabled: (enabled: boolean) => void
6162
/** Ends every open agent shell when the surface is turned off. */
6263
setTerminalEnabled: (enabled: boolean) => void
64+
/** Starts or stops keeping the machine awake for background work already running. */
65+
setPreventSleepWhileRunning: (enabled: boolean) => void
6366
/** Repaints current browser tabs when their persisted appearance changes. */
6467
setBrowserTheme: (theme: DesktopAppearanceTheme) => void
6568
/** Applies a new default zoom to current and future browser tabs. */
@@ -93,6 +96,7 @@ function readPreferences(
9396
browserEnabled: config.get('browserEnabled') ?? true,
9497
browserSearchSuggestionsEnabled: config.get('browserSearchSuggestionsEnabled') ?? true,
9598
terminalEnabled: config.get('terminalEnabled') ?? true,
99+
preventSleepWhileRunning: config.get('preventSleepWhileRunning') ?? true,
96100
browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app',
97101
browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100,
98102
browserDownloadDirectory:
@@ -160,6 +164,12 @@ export function createDesktopSettingsService(
160164
deps.config.flush()
161165
return read()
162166
},
167+
setPreventSleepWhileRunning(enabled) {
168+
deps.config.set('preventSleepWhileRunning', enabled)
169+
deps.config.flush()
170+
deps.setPreventSleepWhileRunning(enabled)
171+
return read()
172+
},
163173
setAppearancePreference(key, value) {
164174
const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined
165175
deps.config.set(key, value)

0 commit comments

Comments
 (0)