Skip to content

Commit 088574f

Browse files
committed
fix(desktop): preserve action evidence in unload completions
1 parent 066a33a commit 088574f

3 files changed

Lines changed: 276 additions & 6 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/** @vitest-environment jsdom */
2+
import { Blob as NodeBlob } from 'node:buffer'
3+
import { ComputerUseError } from '@sim/desktop-bridge'
4+
import { type ComputerUseResult, ComputerUseResultSchema } from '@sim/desktop-bridge/computer-use'
5+
import { flushMicrotasks } from '@sim/testing/helpers/async'
6+
import { createDeferred } from '@sim/testing/helpers/deferred'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { copilotConfirmBodySchema } from '@/lib/api/contracts/copilot'
9+
import { executeComputerToolOnClient } from '@/lib/mothership/tools/client/computer-tool-execution'
10+
11+
const native = vi.hoisted(() => ({ execute: vi.fn(), cancel: vi.fn() }))
12+
vi.mock('@/lib/computer-use/transport', () => ({
13+
executeComputerUseTool: native.execute,
14+
cancelComputerUseTool: native.cancel,
15+
}))
16+
17+
let sequence = 0
18+
const nextId = () => `computer-unload-${++sequence}`
19+
20+
describe('native computer completion during page unload', () => {
21+
beforeEach(() => {
22+
vi.useFakeTimers()
23+
vi.stubGlobal('Blob', NodeBlob)
24+
native.execute.mockReset()
25+
native.cancel.mockResolvedValue(undefined)
26+
})
27+
afterEach(() => vi.useRealTimers())
28+
29+
it('preserves partial action progress in unload-safe fallback before HTTP acknowledgment', async () => {
30+
const started = createDeferred<void>()
31+
const acknowledged = createDeferred<Response>()
32+
const fallbackBodies: string[] = []
33+
vi.stubGlobal(
34+
'fetch',
35+
vi.fn<typeof fetch>(async (_url, init) => {
36+
if (init?.keepalive) {
37+
fallbackBodies.push(String(init.body))
38+
return new Response(null, { status: 200 })
39+
}
40+
started.resolve()
41+
return acknowledged.promise
42+
})
43+
)
44+
const beacons: NodeBlob[] = []
45+
Object.defineProperty(navigator, 'sendBeacon', {
46+
configurable: true,
47+
value: (_url: string, body: NodeBlob) => {
48+
beacons.push(body)
49+
return false
50+
},
51+
})
52+
const result: ComputerUseResult = {
53+
kind: 'action',
54+
action: 'input_sequence',
55+
bundleId: 'com.example.Fixture',
56+
dispatched: true,
57+
verified: false,
58+
sequence: { completedSteps: 1, totalSteps: 2, error: 'Focus changed' },
59+
observation: {
60+
kind: 'state',
61+
bundleId: 'com.example.Fixture',
62+
snapshotId: 'after',
63+
windowId: 'window',
64+
windows: [],
65+
nodes: [],
66+
truncated: false,
67+
screenshot: { base64: 'A'.repeat(100_000), mimeType: 'image/png', width: 1, height: 1 },
68+
},
69+
}
70+
native.execute.mockResolvedValueOnce(ComputerUseResultSchema.parse(result))
71+
const id = nextId()
72+
const execution = executeComputerToolOnClient(
73+
id,
74+
{
75+
action: 'input_sequence',
76+
bundleId: 'com.example.Fixture',
77+
snapshotId: 'before',
78+
elementId: 'editor',
79+
steps: [
80+
{ action: 'press_key', key: 'Tab' },
81+
{ action: 'type_text', text: 'text' },
82+
],
83+
observeAfter: {},
84+
},
85+
new Date().toISOString()
86+
)
87+
await started.promise
88+
try {
89+
window.dispatchEvent(new Event('pagehide'))
90+
await flushMicrotasks()
91+
expect(beacons).toHaveLength(1)
92+
expect(beacons[0].size).toBeLessThanOrEqual(48 * 1024)
93+
const payload = copilotConfirmBodySchema.parse(JSON.parse(await beacons[0].text()))
94+
expect(payload).toMatchObject({
95+
toolCallId: id,
96+
status: 'success',
97+
data: {
98+
dispatched: true,
99+
verified: false,
100+
sequence: { completedSteps: 1, totalSteps: 2, error: 'Focus changed' },
101+
},
102+
})
103+
expect(payload.data).not.toHaveProperty('outcomeUnknown', true)
104+
expect(payload.data).not.toHaveProperty('observations')
105+
expect(fallbackBodies).toEqual([await beacons[0].text()])
106+
expect(native.cancel).not.toHaveBeenCalled()
107+
} finally {
108+
acknowledged.resolve(new Response(null, { status: 200 }))
109+
await execution
110+
}
111+
const count = beacons.length
112+
window.dispatchEvent(new Event('pagehide'))
113+
expect(beacons).toHaveLength(count)
114+
expect(native.execute).toHaveBeenCalledOnce()
115+
})
116+
117+
it('bounds escaped non-image result data for unload delivery', async () => {
118+
const started = createDeferred<void>()
119+
const acknowledged = createDeferred<Response>()
120+
vi.stubGlobal(
121+
'fetch',
122+
vi.fn<typeof fetch>(() => {
123+
started.resolve()
124+
return acknowledged.promise
125+
})
126+
)
127+
const bodies: NodeBlob[] = []
128+
Object.defineProperty(navigator, 'sendBeacon', {
129+
configurable: true,
130+
value: (_url: string, body: NodeBlob) => {
131+
bodies.push(body)
132+
return true
133+
},
134+
})
135+
native.execute.mockResolvedValueOnce(
136+
ComputerUseResultSchema.parse({
137+
kind: 'apps',
138+
apps: Array.from({ length: 1000 }, (_, index) => ({
139+
bundleId: `com.example.App${index}`,
140+
name: '\0'.repeat(1024),
141+
isActive: false,
142+
})),
143+
})
144+
)
145+
const id = nextId()
146+
const execution = executeComputerToolOnClient(
147+
id,
148+
{ action: 'list_apps' },
149+
new Date().toISOString()
150+
)
151+
await started.promise
152+
try {
153+
window.dispatchEvent(new Event('pagehide'))
154+
expect(bodies).toHaveLength(1)
155+
expect(bodies[0].size).toBeLessThanOrEqual(48 * 1024)
156+
expect(copilotConfirmBodySchema.parse(JSON.parse(await bodies[0].text()))).toMatchObject({
157+
toolCallId: id,
158+
status: 'success',
159+
data: { resultOmittedDuringPageExit: true },
160+
})
161+
} finally {
162+
acknowledged.resolve(new Response(null, { status: 200 }))
163+
await execution
164+
}
165+
})
166+
167+
it('retains confirmed zero-dispatch failure after all delivery paths fail and releases it after replay acknowledgment', async () => {
168+
native.execute.mockRejectedValueOnce(
169+
new ComputerUseError({
170+
code: 'activation_required',
171+
message: 'Activate first.',
172+
dispatchState: 'not_started',
173+
})
174+
)
175+
const fetch = vi
176+
.fn<typeof globalThis.fetch>()
177+
.mockResolvedValue(new Response(null, { status: 503 }))
178+
vi.stubGlobal('fetch', fetch)
179+
const bodies: NodeBlob[] = []
180+
Object.defineProperty(navigator, 'sendBeacon', {
181+
configurable: true,
182+
value: (_url: string, body: NodeBlob) => {
183+
bodies.push(body)
184+
return false
185+
},
186+
})
187+
const id = nextId()
188+
const timestamp = new Date().toISOString()
189+
const execution = executeComputerToolOnClient(id, { action: 'list_apps' }, timestamp)
190+
await vi.runAllTimersAsync()
191+
await execution
192+
try {
193+
window.dispatchEvent(new Event('pagehide'))
194+
await flushMicrotasks()
195+
expect(bodies).toHaveLength(1)
196+
expect(copilotConfirmBodySchema.parse(JSON.parse(await bodies[0].text()))).toMatchObject({
197+
toolCallId: id,
198+
status: 'error',
199+
data: {
200+
code: 'activation_required',
201+
dispatchState: 'not_started',
202+
doNotRetry: false,
203+
outcomeUnknown: false,
204+
},
205+
})
206+
const fallback = fetch.mock.calls.find(([, init]) => init?.keepalive)
207+
expect(fallback?.[1]?.body).toBe(await bodies[0].text())
208+
expect(native.cancel).not.toHaveBeenCalled()
209+
} finally {
210+
fetch.mockResolvedValue(new Response(null, { status: 200 }))
211+
await executeComputerToolOnClient(id, { action: 'list_apps' }, timestamp)
212+
}
213+
const count = bodies.length
214+
window.dispatchEvent(new Event('pagehide'))
215+
expect(bodies).toHaveLength(count)
216+
expect(
217+
copilotConfirmBodySchema.parse(JSON.parse(String(fetch.mock.lastCall?.[1]?.body)))
218+
).toMatchObject({
219+
toolCallId: id,
220+
status: 'error',
221+
data: { dispatchState: 'not_started', outcomeUnknown: false },
222+
})
223+
expect(native.execute).toHaveBeenCalledOnce()
224+
})
225+
226+
it('reports an uncertain action and cancels only while native work is still pending', async () => {
227+
const nativeResult = createDeferred<ComputerUseResult>()
228+
native.execute.mockReturnValueOnce(nativeResult.promise)
229+
vi.stubGlobal(
230+
'fetch',
231+
vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 200 }))
232+
)
233+
const bodies: NodeBlob[] = []
234+
Object.defineProperty(navigator, 'sendBeacon', {
235+
configurable: true,
236+
value: (_url: string, body: NodeBlob) => {
237+
bodies.push(body)
238+
return true
239+
},
240+
})
241+
const id = nextId()
242+
const execution = executeComputerToolOnClient(
243+
id,
244+
{ action: 'list_apps' },
245+
new Date().toISOString()
246+
)
247+
try {
248+
window.dispatchEvent(new Event('pagehide'))
249+
expect(bodies).toHaveLength(1)
250+
expect(copilotConfirmBodySchema.parse(JSON.parse(await bodies[0].text()))).toMatchObject({
251+
toolCallId: id,
252+
status: 'error',
253+
data: { outcomeUnknown: true, doNotRetry: true },
254+
})
255+
expect(native.cancel).toHaveBeenCalledExactlyOnceWith(id)
256+
} finally {
257+
nativeResult.resolve({ kind: 'apps', apps: [] })
258+
await execution
259+
}
260+
})
261+
})

‎apps/sim/lib/mothership/tools/client/page-exit-completion.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { toRecord } from '@sim/utils/object'
1+
import { omit, toRecord } from '@sim/utils/object'
22
import { truncate } from '@sim/utils/string'
33
import type { AsyncConfirmationStatus } from '@/lib/mothership/async-runs/lifecycle'
44

@@ -13,13 +13,23 @@ export function compactCompletionForPageExit(
1313
toolCallId: string,
1414
completion: ClientToolCompletion
1515
): ClientToolCompletion {
16-
const serialized = JSON.stringify({ toolCallId, ...completion })
17-
if (new Blob([serialized]).size <= PAGE_EXIT_COMPLETION_MAX_BYTES) return completion
16+
const fits = (value: ClientToolCompletion) =>
17+
new Blob([JSON.stringify({ toolCallId, ...value })]).size <= PAGE_EXIT_COMPLETION_MAX_BYTES
18+
if (fits(completion)) return completion
1819

1920
const data = toRecord(completion.data)
20-
return {
21-
status: completion.status,
21+
const compact = {
22+
...completion,
2223
message: truncate(completion.message, 1024),
24+
data: {
25+
...omit(data, ['observations']),
26+
resultOmittedDuringPageExit: true,
27+
note: 'The result was compacted for unload-safe delivery. Inspect current state without repeating input.',
28+
},
29+
}
30+
if (fits(compact)) return compact
31+
return {
32+
...compact,
2333
data: {
2434
...(data.outcomeUnknown === true ? { outcomeUnknown: true } : {}),
2535
...(data.doNotRetry === true ? { doNotRetry: true } : {}),

‎scripts/check-unused-exports.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,6 @@
13941394
"apps/sim/lib/api/contracts/copilot.ts#copilotChatListItemSchema",
13951395
"apps/sim/lib/api/contracts/copilot.ts#copilotChatSteerBodySchema",
13961396
"apps/sim/lib/api/contracts/copilot.ts#copilotChatStreamQuerySchema",
1397-
"apps/sim/lib/api/contracts/copilot.ts#copilotConfirmBodySchema",
13981397
"apps/sim/lib/api/contracts/copilot.ts#copilotToolPermissionBodySchema",
13991398
"apps/sim/lib/api/contracts/copilot.ts#copilotToolPermissionDecisionSchema",
14001399
"apps/sim/lib/api/contracts/copilot.ts#createWorkflowCopilotChatBodySchema",

0 commit comments

Comments
 (0)