Skip to content

Commit 88aeaa9

Browse files
committed
fix(desktop): harden browser and terminal tab activity
1 parent 25d3bf2 commit 88aeaa9

39 files changed

Lines changed: 2166 additions & 231 deletions

File tree

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,150 @@ describe('executeTool', () => {
176176
}
177177
})
178178

179+
it('cancels the exact takeover and clears its attention state immediately', async () => {
180+
await driver.executeTool('chat-test', 'browser_open_tab', {})
181+
vi.useFakeTimers()
182+
try {
183+
const takeover = driver.executeTool(
184+
'chat-test',
185+
'browser_request_takeover',
186+
{ reason: 'Please finish in the browser' },
187+
'tool-takeover'
188+
)
189+
await vi.advanceTimersByTimeAsync(0)
190+
expect(session.getTabsState().automationNeedsAttention).toBe(true)
191+
192+
expect(driver.cancelTool('chat-test', 'tool-takeover')).toBe(true)
193+
expect(session.getTabsState().automationNeedsAttention).toBe(false)
194+
await vi.advanceTimersByTimeAsync(1_500)
195+
196+
await expect(takeover).resolves.toMatchObject({
197+
ok: false,
198+
error: expect.stringContaining('cancelled'),
199+
})
200+
await expect(
201+
driver.executeTool('chat-test', 'browser_list_tabs', {}, 'tool-takeover')
202+
).resolves.toMatchObject({
203+
ok: false,
204+
error: expect.stringContaining('cancelled before it started'),
205+
})
206+
} finally {
207+
vi.useRealTimers()
208+
}
209+
})
210+
211+
it('does not let cancelled takeover cleanup clear a newer takeover', async () => {
212+
await driver.executeTool('chat-test', 'browser_open_tab', {})
213+
vi.useFakeTimers()
214+
try {
215+
const firstTakeover = driver.executeTool(
216+
'chat-test',
217+
'browser_request_takeover',
218+
{ reason: 'First handoff' },
219+
'tool-takeover-first'
220+
)
221+
await vi.advanceTimersByTimeAsync(0)
222+
223+
expect(driver.cancelTool('chat-test', 'tool-takeover-first')).toBe(true)
224+
await expect(firstTakeover).resolves.toMatchObject({
225+
ok: false,
226+
error: expect.stringContaining('cancelled'),
227+
})
228+
229+
const secondTakeover = driver.executeTool(
230+
'chat-test',
231+
'browser_request_takeover',
232+
{ reason: 'Second handoff' },
233+
'tool-takeover-second'
234+
)
235+
await vi.advanceTimersByTimeAsync(0)
236+
expect(session.getTabsState().automationNeedsAttention).toBe(true)
237+
238+
// Let the detached first takeover observe cancellation and run finally.
239+
await vi.advanceTimersByTimeAsync(1_500)
240+
expect(session.getTabsState().automationNeedsAttention).toBe(true)
241+
242+
await driver.handlePanelAction('chat-test', { action: 'takeover-done' })
243+
await vi.advanceTimersByTimeAsync(1_500)
244+
await expect(secondTakeover).resolves.toMatchObject({
245+
ok: true,
246+
result: { completed: true },
247+
})
248+
} finally {
249+
vi.useRealTimers()
250+
}
251+
})
252+
253+
it('honors cancellation that arrives before the authorized tool invocation', async () => {
254+
expect(driver.cancelTool('chat-test', 'tool-before-authorization')).toBe(true)
255+
256+
await expect(
257+
driver.executeTool('chat-test', 'browser_list_tabs', {}, 'tool-before-authorization')
258+
).resolves.toMatchObject({
259+
ok: false,
260+
error: expect.stringContaining('cancelled before it started'),
261+
})
262+
})
263+
264+
it('settles native automation activity immediately when an active tool is cancelled', async () => {
265+
await driver.executeTool('chat-test', 'browser_open_tab', {})
266+
vi.useFakeTimers()
267+
try {
268+
const waiting = driver.executeTool(
269+
'chat-test',
270+
'browser_wait_for',
271+
{ timeoutMs: 120_000 },
272+
'tool-waiting'
273+
)
274+
await vi.advanceTimersByTimeAsync(0)
275+
expect(session.getTabsState().automationActive).toBe(true)
276+
277+
expect(driver.cancelActiveTool('chat-test')).toBe(true)
278+
await vi.advanceTimersByTimeAsync(0)
279+
expect(session.getTabsState().automationActive).toBe(false)
280+
await expect(waiting).resolves.toMatchObject({
281+
ok: false,
282+
error: expect.stringContaining('cancelled'),
283+
})
284+
} finally {
285+
vi.useRealTimers()
286+
}
287+
})
288+
289+
it('cancels queued pre-boundary tools while allowing later browser work', async () => {
290+
await driver.executeTool('chat-test', 'browser_open_tab', {})
291+
vi.useFakeTimers()
292+
try {
293+
const waiting = driver.executeTool(
294+
'chat-test',
295+
'browser_wait_for',
296+
{ timeoutMs: 120_000 },
297+
'tool-active'
298+
)
299+
await vi.advanceTimersByTimeAsync(0)
300+
const queuedOpen = driver.executeTool('chat-test', 'browser_open_tab', {}, 'tool-queued')
301+
302+
expect(driver.cancelActiveTool('chat-test')).toBe(true)
303+
304+
await expect(waiting).resolves.toMatchObject({
305+
ok: false,
306+
error: expect.stringContaining('cancelled'),
307+
})
308+
await expect(queuedOpen).resolves.toMatchObject({
309+
ok: false,
310+
error: expect.stringContaining('cancelled before it started'),
311+
})
312+
expect(session.getTabsState().tabs).toHaveLength(1)
313+
314+
await expect(
315+
driver.executeTool('chat-test', 'browser_open_tab', {}, 'tool-after-boundary')
316+
).resolves.toMatchObject({ ok: true })
317+
expect(session.getTabsState().tabs).toHaveLength(2)
318+
} finally {
319+
vi.useRealTimers()
320+
}
321+
})
322+
179323
it('releases an abandoned takeover when a newer browser action arrives', async () => {
180324
await driver.executeTool('chat-test', 'browser_open_tab', {})
181325
vi.useFakeTimers()

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

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,16 @@ interface DriverScopeState {
114114
takeoverActive: boolean
115115
takeoverDone: boolean
116116
takeoverResponse: string | null
117+
/** Invocation that currently owns the shared takeover response state. */
118+
takeoverInvocationEpoch: number | null
117119
/** Monotonic browser-tool invocation id used to supersede an abandoned takeover. */
118120
toolInvocationEpoch: number
121+
/** Exact client tool currently at the head of this scope's serialized queue. */
122+
activeToolCallId: string | null
123+
/** Rejects the active queue entry while its underlying Chromium work winds down. */
124+
activeToolCancel: (() => void) | null
125+
/** Invalidates every invocation already queued when a scope-level cancel establishes a boundary. */
126+
toolQueueCancellationEpoch: number
119127
lastTabsStateFingerprint: string | null
120128
toolQueue: Promise<unknown>
121129
/** True while activation is the only operation that has touched this scope. */
@@ -132,13 +140,23 @@ interface DriverScopeState {
132140
toolExecutionEpoch: number
133141
}
134142

143+
/** Captures the native queue boundary before an async authorization round trip. */
144+
export interface BrowserToolQueueBoundary {
145+
scopeId: string
146+
cancellationEpoch: number
147+
}
148+
135149
function createDriverScopeState(): DriverScopeState {
136150
return {
137151
pendingNotices: [],
138152
takeoverActive: false,
139153
takeoverDone: false,
140154
takeoverResponse: null,
155+
takeoverInvocationEpoch: null,
141156
toolInvocationEpoch: 0,
157+
activeToolCallId: null,
158+
activeToolCancel: null,
159+
toolQueueCancellationEpoch: 0,
142160
lastTabsStateFingerprint: null,
143161
toolQueue: Promise.resolve(),
144162
activationOnly: true,
@@ -158,6 +176,22 @@ function invalidateSnapshot(state = driverScopeState()): void {
158176

159177
const driverScopeStates = new Map<string, DriverScopeState>()
160178
const driverScopeAliases = new Map<string, string>()
179+
const CANCELLED_TOOL_TTL_MS = 5 * 60_000
180+
const MAX_CANCELLED_TOOL_TOMBSTONES = 256
181+
const cancelledToolCallIds = new Map<string, number>()
182+
183+
function pruneCancelledToolCallIds(now = Date.now()): void {
184+
for (const [toolCallId, expiresAt] of cancelledToolCallIds) {
185+
if (expiresAt > now && cancelledToolCallIds.size <= MAX_CANCELLED_TOOL_TOMBSTONES) break
186+
cancelledToolCallIds.delete(toolCallId)
187+
}
188+
}
189+
190+
function isToolCallCancelled(toolCallId: string | undefined): boolean {
191+
if (!toolCallId) return false
192+
pruneCancelledToolCallIds()
193+
return cancelledToolCallIds.has(toolCallId)
194+
}
161195

162196
function resolveDriverScopeId(scopeId: string): string {
163197
let resolved = scopeId
@@ -179,6 +213,19 @@ function driverScopeState(scopeId = session.getBrowserScopeId()): DriverScopeSta
179213
return state
180214
}
181215

216+
export function captureBrowserToolQueueBoundary(scopeId: string): BrowserToolQueueBoundary {
217+
const resolvedScopeId = resolveDriverScopeId(scopeId)
218+
return {
219+
scopeId: resolvedScopeId,
220+
cancellationEpoch: driverScopeState(resolvedScopeId).toolQueueCancellationEpoch,
221+
}
222+
}
223+
224+
function isBrowserToolQueueBoundaryCurrent(boundary: BrowserToolQueueBoundary): boolean {
225+
const state = driverScopeStates.get(resolveDriverScopeId(boundary.scopeId))
226+
return state?.toolQueueCancellationEpoch === boundary.cancellationEpoch
227+
}
228+
182229
function recordNotice(notice: string): void {
183230
const state = driverScopeState()
184231
state.activationOnly = false
@@ -313,6 +360,7 @@ export function initDriver(
313360
// first tab push as a duplicate.
314361
driverScopeStates.clear()
315362
driverScopeAliases.clear()
363+
cancelledToolCallIds.clear()
316364
// The serialization chain, too. A takeover from the previous session can sit
317365
// unresolved indefinitely, and its `takeoverDone` flag is reset above — so
318366
// leaving the old chain head in place would queue the new session's first
@@ -1644,6 +1692,7 @@ async function runTakeover(purpose: string | undefined, invocationEpoch: number)
16441692
state.takeoverActive = true
16451693
state.takeoverDone = false
16461694
state.takeoverResponse = null
1695+
state.takeoverInvocationEpoch = invocationEpoch
16471696
session.setAutomationNeedsAttention(true)
16481697

16491698
const startedAt = Date.now()
@@ -1673,10 +1722,16 @@ async function runTakeover(purpose: string | undefined, invocationEpoch: number)
16731722
}
16741723
}
16751724
} finally {
1676-
session.setAutomationNeedsAttention(false)
1677-
state.takeoverActive = false
1678-
state.takeoverDone = false
1679-
state.takeoverResponse = null
1725+
// Cancellation releases the serialized tool queue before this polling
1726+
// loop observes its superseding epoch. Never let that delayed cleanup
1727+
// erase a newer takeover that has already claimed the same scope state.
1728+
if (state.takeoverInvocationEpoch === invocationEpoch) {
1729+
session.setAutomationNeedsAttention(false)
1730+
state.takeoverActive = false
1731+
state.takeoverDone = false
1732+
state.takeoverResponse = null
1733+
state.takeoverInvocationEpoch = null
1734+
}
16801735
}
16811736
}
16821737

@@ -2987,7 +3042,9 @@ function withNotices(result: unknown): unknown {
29873042
export async function executeTool(
29883043
scopeId: string,
29893044
tool: BrowserToolName,
2990-
params: Record<string, unknown>
3045+
params: Record<string, unknown>,
3046+
toolCallId?: string,
3047+
authorizationBoundary?: BrowserToolQueueBoundary
29913048
): Promise<{ ok: boolean; result?: unknown; error?: string }> {
29923049
const resolvedScopeId = resolveDriverScopeId(scopeId)
29933050
if (session.isBrowserScopeSuspended(resolvedScopeId)) {
@@ -2999,7 +3056,21 @@ export async function executeTool(
29993056
const state = driverScopeState(resolvedScopeId)
30003057
state.activationOnly = false
30013058
const invocationEpoch = ++state.toolInvocationEpoch
3059+
const queueCancellationEpoch = state.toolQueueCancellationEpoch
30023060
const run = async () => {
3061+
if (
3062+
(authorizationBoundary && !isBrowserToolQueueBoundaryCurrent(authorizationBoundary)) ||
3063+
queueCancellationEpoch !== state.toolQueueCancellationEpoch ||
3064+
isToolCallCancelled(toolCallId)
3065+
) {
3066+
throw new ToolError('This browser action was cancelled before it started.')
3067+
}
3068+
state.activeToolCallId = toolCallId ?? null
3069+
let cancelActiveExecution: () => void = () => {}
3070+
const cancellation = new Promise<never>((_resolve, reject) => {
3071+
cancelActiveExecution = () => reject(new ToolError('This browser action was cancelled.'))
3072+
})
3073+
state.activeToolCancel = cancelActiveExecution
30033074
return await session.withBrowserScope(resolvedScopeId, async () => {
30043075
logger.info('Executing browser tool', { tool, scopeId: resolvedScopeId })
30053076
const keepHiddenPageActive = tool !== 'browser_request_takeover'
@@ -3022,20 +3093,24 @@ export async function executeTool(
30223093
executionDeadline,
30233094
invocationEpoch
30243095
)
3025-
return withNotices(
3026-
await (watchdogMs === null
3096+
const guardedExecution =
3097+
watchdogMs === null
30273098
? execution
30283099
: raceAgainstWatchdog(execution, watchdogMs, () => {
30293100
if (state.toolExecutionEpoch === executionEpoch) state.toolExecutionEpoch++
30303101
if (tool === 'browser_snapshot' || tool === 'browser_open_url') {
30313102
invalidateSnapshot(state)
30323103
}
3033-
}))
3034-
)
3104+
})
3105+
return withNotices(await Promise.race([guardedExecution, cancellation]))
30353106
} finally {
30363107
if (keepHiddenPageActive) {
30373108
session.setAutomationActive(false)
30383109
}
3110+
if (state.activeToolCancel === cancelActiveExecution) {
3111+
state.activeToolCallId = null
3112+
state.activeToolCancel = null
3113+
}
30393114
}
30403115
})
30413116
}
@@ -3057,6 +3132,40 @@ export async function executeTool(
30573132
}
30583133
}
30593134

3135+
/**
3136+
* Cancels one exact browser tool, including a cancellation that arrives while
3137+
* its authorization IPC is still in flight. The bounded tombstone lets that
3138+
* later invocation observe the stop without retaining call ids indefinitely.
3139+
*/
3140+
export function cancelTool(scopeId: string, toolCallId: string): boolean {
3141+
pruneCancelledToolCallIds()
3142+
cancelledToolCallIds.set(toolCallId, Date.now() + CANCELLED_TOOL_TTL_MS)
3143+
pruneCancelledToolCallIds()
3144+
3145+
const resolvedScopeId = resolveDriverScopeId(scopeId)
3146+
const state = driverScopeStates.get(resolvedScopeId)
3147+
if (!state || state.activeToolCallId !== toolCallId) return true
3148+
3149+
state.toolInvocationEpoch++
3150+
state.toolExecutionEpoch++
3151+
state.activeToolCancel?.()
3152+
void session.withBrowserScope(resolvedScopeId, () => {
3153+
session.setAutomationActive(false)
3154+
session.setAutomationNeedsAttention(false)
3155+
})
3156+
return true
3157+
}
3158+
3159+
/** Cancels the active tool and every older invocation already queued for this scope. */
3160+
export function cancelActiveTool(scopeId: string): boolean {
3161+
const resolvedScopeId = resolveDriverScopeId(scopeId)
3162+
const state = driverScopeStates.get(resolvedScopeId)
3163+
if (!state) return false
3164+
state.toolQueueCancellationEpoch++
3165+
const toolCallId = state.activeToolCallId
3166+
return toolCallId ? cancelTool(resolvedScopeId, toolCallId) : false
3167+
}
3168+
30603169
/** Browser-chrome commands from the panel header; fire-and-forget. */
30613170
export async function handlePanelAction(
30623171
scopeId: string,

0 commit comments

Comments
 (0)