Skip to content

Commit d23e403

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

4 files changed

Lines changed: 97 additions & 20 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,44 @@ test.describe('background executor', () => {
769769
})
770770
})
771771

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+
772810
test('J: the machine stays awake only while a chat has work running', async () => {
773811
app = (await launch(mkdtempSync(join(tmpdir(), 'sim-executor-j-')))).app
774812
await app.evaluate(({ powerSaveBlocker }) => {

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5096,6 +5096,12 @@ function withNotices(result: unknown): unknown {
50965096

50975097
/** Quiet time after the user's last click or keystroke before the agent takes the page back. */
50985098
const USER_TAKEOVER_IDLE_MS = 4_000
5099+
/** The longest an action waits for the user to finish; past it, the action does not run. */
5100+
const USER_TAKEOVER_MAX_WAIT_MS = 30_000
5101+
const USER_KEPT_WORKING =
5102+
'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.'
5103+
const USER_TOOK_OVER_MID_ACTION =
5104+
'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.'
50995105

51005106
function isUserWorkingInPage(): boolean {
51015107
const since = session.msSinceUserIntervention()
@@ -5107,14 +5113,25 @@ function isUserWorkingInPage(): boolean {
51075113
* or scrolling in the tab the agent drives, the agent's next action waits, marked as needing
51085114
* attention, and resumes once the user has left the page alone for a few seconds.
51095115
*/
5110-
async function yieldToUser(toolCallId: string | undefined, signal: AbortSignal): Promise<void> {
5116+
async function yieldToUser(
5117+
toolCallId: string | undefined,
5118+
signal: AbortSignal,
5119+
maxWaitMs: number
5120+
): Promise<void> {
51115121
session.setAutomationNeedsAttention(true)
51125122
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.')
5123+
// Bounded well inside the action's own deadline, so a user who keeps working gets the model a
5124+
// clear "not run" rather than a timeout with an unknown outcome.
5125+
const deadline = Date.now() + maxWaitMs
5126+
try {
5127+
while (isUserWorkingInPage()) {
5128+
if (Date.now() >= deadline) throw new ToolError(USER_KEPT_WORKING)
5129+
await interruptibleSleep(250, signal)
5130+
if (signal.aborted) throw new ToolError('This browser action was cancelled.')
5131+
}
5132+
} finally {
5133+
session.setAutomationNeedsAttention(false)
51165134
}
5117-
session.setAutomationNeedsAttention(false)
51185135
logger.info('Browser automation resumed after the user stopped', { toolCallId })
51195136
}
51205137

@@ -5214,20 +5231,32 @@ export async function executeTool(
52145231
session.setAutomationActive(true)
52155232
}
52165233
try {
5217-
if (tool !== 'browser_request_takeover' && isUserWorkingInPage()) {
5218-
await yieldToUser(toolCallId, executionController.signal)
5234+
const yieldsToUser = tool !== 'browser_request_takeover'
5235+
const watchdogMs = browserToolWatchdogMs(tool, params)
5236+
if (yieldsToUser && isUserWorkingInPage()) {
5237+
await yieldToUser(
5238+
toolCallId,
5239+
executionController.signal,
5240+
watchdogMs === null
5241+
? USER_TAKEOVER_MAX_WAIT_MS
5242+
: Math.min(USER_TAKEOVER_MAX_WAIT_MS, Math.floor(watchdogMs / 2))
5243+
)
52195244
}
52205245
const response = dialogResponse(tool, params)
52215246
state.dialogResponse = response
52225247
? { contents: session.requireAutomationTab().view.webContents, response }
52235248
: null
52245249
const executionEpoch = ++state.toolExecutionEpoch
5225-
const watchdogMs = browserToolWatchdogMs(tool, params)
52265250
const executionDeadline = watchdogMs === null ? undefined : Date.now() + watchdogMs
5251+
// Checked right before each input the action sends: a user who starts working in the
5252+
// page mid-action (between a batch's steps, during a click's target probes) wins it.
52275253
const assertCurrentExecution = () => {
52285254
if (state.toolExecutionEpoch !== executionEpoch) {
52295255
throw new ToolError('This browser action expired before it could dispatch input.')
52305256
}
5257+
if (yieldsToUser && isUserWorkingInPage()) {
5258+
throw new ToolError(USER_TOOK_OVER_MID_ACTION)
5259+
}
52315260
}
52325261
let actionOutcome: BrowserActionOutcome | undefined
52335262
const execution = withPostActionObservation(

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,11 @@ 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
301+
/**
302+
* When the user last clicked, scrolled or typed in the tab the agent drives, and in which tab:
303+
* activity in a tab the agent no longer drives (closed, replaced) holds nothing up.
304+
*/
305+
userIntervention: { tabId: string; at: number } | null
303306
}
304307

305308
function createBrowserScopeState(): BrowserScopeState {
@@ -321,7 +324,7 @@ function createBrowserScopeState(): BrowserScopeState {
321324
automationNeedsAttention: false,
322325
findingTabId: null,
323326
findingRequestId: null,
324-
userInterventionAt: null,
327+
userIntervention: null,
325328
}
326329
}
327330

@@ -2371,7 +2374,7 @@ function initializeTabView(
23712374
return
23722375
}
23732376
const tab = tabs.find((entry) => entry.view.webContents === contents)
2374-
if (tab?.id === currentScope.automationTabId) noteUserIntervention()
2377+
if (tab && tab.id === currentScope.automationTabId) noteUserIntervention(tab.id)
23752378
if (tab?.id === currentScope.activeTabId) {
23762379
currentScope.visibleTabUserSelected = true
23772380
if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now()
@@ -2491,9 +2494,10 @@ function initializeTabView(
24912494
if (
24922495
!isDispatchingAgentInput(contents) &&
24932496
input.type === 'keyDown' &&
2494-
tab?.id === currentScope.automationTabId
2497+
tab &&
2498+
tab.id === currentScope.automationTabId
24952499
) {
2496-
noteUserIntervention()
2500+
noteUserIntervention(tab.id)
24972501
}
24982502
if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) {
24992503
currentScope.visibleTabUserSelected = true
@@ -3129,19 +3133,20 @@ export function claimActiveTabForUser(): AgentTab | null {
31293133
}
31303134

31313135
/** 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()
3136+
function noteUserIntervention(tabId: string): void {
3137+
currentScope.userIntervention = { tabId, at: Date.now() }
31343138
}
31353139

3136-
/** How long ago the user last acted in the current scope's agent tab, or null if never. */
3140+
/** How long ago the user last acted in the tab the agent drives now, or null if never. */
31373141
export function msSinceUserIntervention(): number | null {
3138-
const at = currentScope.userInterventionAt
3139-
return at === null ? null : Math.max(0, Date.now() - at)
3142+
const intervention = currentScope.userIntervention
3143+
if (!intervention || intervention.tabId !== currentScope.automationTabId) return null
3144+
return Math.max(0, Date.now() - intervention.at)
31403145
}
31413146

31423147
/** Explicit hand-back after takeover lets automation resume in the same page. */
31433148
export function returnAutomationTabToAgent(): void {
3144-
currentScope.userInterventionAt = null
3149+
currentScope.userIntervention = null
31453150
if (currentScope.activeTabId === currentScope.automationTabId) {
31463151
currentScope.visibleTabUserSelected = false
31473152
}

‎apps/desktop/src/main/desktop-executor/sleep-blocker.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ interface SleepBlockerDeps {
1616
powerSaveBlocker: PowerSaveBlocker
1717
}
1818

19+
/**
20+
* Holds one `prevent-app-suspension` power blocker while the executor is busy and the switch is
21+
* on, and releases it as soon as either stops being true. `setBusy` follows the executor;
22+
* `refresh` follows the switch.
23+
*/
1924
export function createSleepBlocker(deps: SleepBlockerDeps) {
2025
let busy = false
2126
let blockerId: number | null = null

0 commit comments

Comments
 (0)