Skip to content

Commit f2be555

Browse files
committed
test(desktop): run the live desktop suite with the background executor on
The live suite's Sim has Redis, so with the executor shipped always-on every turn now binds to the app and runs in the background. The proxy answers the app's registration as an install without Redis would, so the chat-view tests keep covering that path, and the round trip asserts the app stays dormant. Two tests let Sim's own answer through: a call issued after the user switched chats runs on the desktop, and a result reported across a network cut (every connection dropped, the first report landing late) reaches the agent exactly once.
1 parent 5b6ce1b commit f2be555

5 files changed

Lines changed: 190 additions & 74 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,9 @@ jobs:
307307
key: ${{ steps.next-cache.outputs.key }}
308308
path: ./apps/sim/.next/dev
309309

310-
# Chat switches, Stop, sign-out, approval and the flag-off foreground round trip. The spec
311-
# runs the recording proxy (the app's public origin) and the stand-in worker.
310+
# Chat switches, Stop, sign-out, approval, the dormant-executor round trip, and background
311+
# runs across a chat switch and a network cut. The spec runs the recording proxy (the app's
312+
# public origin) and the stand-in worker.
312313
- name: Verify desktop tools in the Electron app against a local app
313314
env:
314315
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3020

‎apps/desktop/e2e/desktop-tools-live-sim.spec.ts‎

Lines changed: 113 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
import type { SimDesktopApi } from '@sim/desktop-bridge'
1414
import { sleep } from '@sim/utils/helpers'
1515
import { generateId } from '@sim/utils/id'
16-
import { toRecord } from '@sim/utils/object'
1716
import {
1817
type LiveSimConfig,
1918
liveSimConfig,
@@ -30,6 +29,11 @@ import {
3029
* model's decisions are scripted (a stand-in worker at `SIM_AGENT_API_URL`). Each test checks
3130
* what the user or the model would observe: the result the model is resumed with, what landed in
3231
* the workspace, what Sim persisted, and which requests reached Sim.
32+
*
33+
* Sim runs the background executor wherever it has Redis, as it does here. Most tests cover the
34+
* chat view running desktop tools itself, as on an install without Redis: the proxy answers the
35+
* app's registration as such an install does, so the app stays dormant and no turn binds to it.
36+
* The tests marked as running in the background let Sim's own answer through.
3337
*/
3438

3539
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
@@ -43,6 +47,11 @@ const LEASE_MS = 60_000
4347
const ARRIVAL_MS = 60_000
4448
/** First requests to a route compile it, which takes minutes on a cold dev app. */
4549
const COMPILE_MS = 300_000
50+
const REGISTRATION_PATH = '/api/desktop/devices'
51+
/** Sim's answer to registration on an install that cannot run the background executor. */
52+
const executorUnavailable = (answer: Record<string, unknown>) => {
53+
answer.enabled = false
54+
}
4655

4756
type DesktopWindow = typeof globalThis & { simDesktop: SimDesktopApi }
4857

@@ -68,6 +77,7 @@ test.describe('desktop tools against a live Sim', () => {
6877
agent = new ScriptedAgent(sim.agentPort)
6978
db = new SimDatabase(sim)
7079
await Promise.all([proxy.start(), agent.start()])
80+
proxy.rewriteAnswer(REGISTRATION_PATH, executorUnavailable)
7181
await test.step('warm up the routes the tests use', warmUp)
7282
})
7383

@@ -106,9 +116,10 @@ test.describe('desktop tools against a live Sim', () => {
106116
)
107117
}
108118
proxy.clearHolds()
119+
proxy.restoreNetwork()
109120
await app?.close().catch(() => {})
110121
app = undefined
111-
proxy.rewriteChatBody(undefined)
122+
proxy.rewriteAnswer(REGISTRATION_PATH, executorUnavailable)
112123
rmSync(scratch, { recursive: true, force: true })
113124
})
114125

@@ -148,13 +159,16 @@ test.describe('desktop tools against a live Sim', () => {
148159
'/api/copilot/chats',
149160
'/api/users/me/settings',
150161
'/api/auth/oauth/connections',
162+
'/api/desktop/inbox',
151163
])
152164
await compile(path)
153165
for (const path of [
154166
'/api/mothership/chat/stop',
155167
'/api/mothership/chat/abort',
156168
'/api/desktop/devices',
157169
'/api/desktop/tool/authorize',
170+
'/api/desktop/tool/claim',
171+
'/api/desktop/tool/complete',
158172
'/api/copilot/confirm',
159173
'/api/files/uploads',
160174
'/api/files/uploads/warm-up/parts',
@@ -361,6 +375,9 @@ test.describe('desktop tools against a live Sim', () => {
361375
/** The chat turn's response stream, as the chat view reads it. */
362376
const isChatStream = (entry: { method: string; path: string }) =>
363377
entry.method === 'POST' && entry.path === '/api/mothership/chat'
378+
/** The background executor's report of a call's result. */
379+
const isDesktopCompletion = (method: string, path: string) =>
380+
method === 'POST' && path === '/api/desktop/tool/complete'
364381
/** A client tool's report of its own result. */
365382
const isToolReport = (method: string, path: string) =>
366383
method === 'POST' && path === '/api/copilot/confirm'
@@ -691,46 +708,13 @@ test.describe('desktop tools against a live Sim', () => {
691708
expect(after).toMatchObject({ status: 'cancelled', claimedBy: null })
692709
})
693710

694-
test('with the background executor off, a foreground desktop round trip never binds a device or rings a doorbell', async () => {
711+
test('where Sim cannot run the background executor, the app stays dormant and a desktop round trip runs in the chat view', async () => {
695712
const user = await db.seedUser(['Round trip'])
696713
const marker = generateId()
697714
const file = writeFile(join(scratch, 'plan.txt'), `plan ${marker}`)
698-
const deviceId = generateId()
699715
const monitor = new RedisMonitor(sim.redisUrl)
700716
await monitor.start()
701717
try {
702-
// A desktop that speaks the executor protocol registers and offers itself for the turn.
703-
const registration = await fetch(new URL('/api/desktop/devices', sim.upstream), {
704-
method: 'POST',
705-
headers: {
706-
'Content-Type': 'application/json',
707-
Cookie: `better-auth.session_token=${user.cookie}`,
708-
Origin: proxy.origin,
709-
'User-Agent': 'Sim Desktop',
710-
},
711-
body: JSON.stringify({
712-
deviceId,
713-
name: 'E2E desktop',
714-
appVersion: '0.9.0',
715-
platform: `${process.platform}-${process.arch}`,
716-
capabilities: { executor: 1, browser: true, terminal: true, localFiles: true },
717-
}),
718-
signal: AbortSignal.timeout(COMPILE_MS),
719-
})
720-
// Each layer of the dormant executor is checked on its own (soft), so a regression shows
721-
// every layer it reaches: the answer to registration, the device record, the turn's
722-
// binding, the routes the app calls, and the doorbell.
723-
expect(registration.status).toBe(200)
724-
expect
725-
.soft(await registration.json(), 'registration answer')
726-
.toMatchObject({ enabled: false })
727-
proxy.rewriteChatBody((body) => {
728-
const desktop = toRecord(body.desktopCapabilities)
729-
// The app offers its own install when it speaks the executor protocol; otherwise offer
730-
// the device registered above, as such a desktop would.
731-
body.desktopCapabilities = { deviceId, executor: 1, ...desktop }
732-
})
733-
734718
let callId = ''
735719
agent.script(
736720
'[round-trip]',
@@ -749,19 +733,19 @@ test.describe('desktop tools against a live Sim', () => {
749733
const since = Date.now()
750734
const page = await openApp(user, 'Round trip')
751735
await send(page, '[round-trip] what does my plan say?')
736+
// Each layer is checked on its own (soft), so a regression shows every layer it reaches:
737+
// the round trip, the turn's binding, the routes the app calls, and the doorbell.
752738
await expect
753739
.soft(page.getByText('The plan says go.'), 'foreground round trip')
754740
.toBeVisible({ timeout: 60_000 })
755741
expect.soft(agent.resultFor(callId)?.success, 'read result').toBe(true)
756-
expect(proxy.rewrittenChatBodies).toBeGreaterThan(0)
757742

758743
// The app registers once signed out (refused) and again on sign-in.
759744
const registeredSignedIn = () =>
760745
proxy
761-
.seen(since, '/api/desktop/devices')
746+
.seen(since, REGISTRATION_PATH)
762747
.some((entry) => entry.method === 'POST' && entry.status === 200)
763748
await expect.poll(registeredSignedIn, { timeout: 30_000 }).toBe(true)
764-
expect.soft(await db.desktopDeviceCount(user.userId), 'device records').toBe(0)
765749

766750
const runs = await db.runs(user.chats['Round trip'])
767751
expect(runs.length).toBeGreaterThan(0)
@@ -778,9 +762,11 @@ test.describe('desktop tools against a live Sim', () => {
778762
expect.soft(call?.persistSeq, 'persist order').not.toBeNull()
779763

780764
// Only registration and the foreground claim: no inbox, doorbell stream, executor claim,
781-
// lease or completion.
765+
// lease or completion. The workspace's activity poll is Sim's page, not the app: this Sim
766+
// has Redis, so its page asks.
782767
const desktopRoutes = new Set(proxy.seen(since, '/api/desktop/').map((entry) => entry.path))
783-
desktopRoutes.delete('/api/desktop/devices')
768+
desktopRoutes.delete(REGISTRATION_PATH)
769+
desktopRoutes.delete('/api/desktop/activity')
784770
expect
785771
.soft(desktopRoutes, 'desktop routes the app called')
786772
.toEqual(new Set(['/api/desktop/tool/authorize']))
@@ -790,4 +776,90 @@ test.describe('desktop tools against a live Sim', () => {
790776
monitor.stop()
791777
}
792778
})
779+
780+
test('in the background, a call issued after the user switched chats runs on the desktop', async () => {
781+
proxy.rewriteAnswer(REGISTRATION_PATH, undefined)
782+
const user = await db.seedUser(['Background chat', 'Other chat'])
783+
const marker = generateId()
784+
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
785+
let issue!: () => void
786+
const issued = new Promise<void>((resolve) => {
787+
issue = resolve
788+
})
789+
let callId = ''
790+
let issuedAt = 0
791+
agent.script('[background-read]', async (turn) => {
792+
turn.text('Reading your notes.')
793+
await issued
794+
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
795+
issuedAt = Date.now()
796+
turn.pause()
797+
})
798+
const page = await openApp(user, 'Background chat')
799+
await send(page, '[background-read] read my notes')
800+
await expect(page.getByText('Reading your notes.')).toBeVisible({ timeout: 60_000 })
801+
await openChat(page, user, 'Other chat')
802+
issue()
803+
804+
await agent.waitForResume(() => Boolean(callId && agent.resultFor(callId)), 60_000)
805+
const result = agent.resultFor(callId)
806+
expect(result?.success).toBe(true)
807+
expect(JSON.stringify(result?.data)).toContain(marker)
808+
// No pickup grace: the desktop, not the chat view the user left, ran it.
809+
expect((result?.at ?? 0) - issuedAt).toBeLessThan(PICKUP_GRACE_MS)
810+
const chatId = user.chats['Background chat']
811+
const runs = await db.runs(chatId)
812+
expect(runs.some((run) => run.desktopDeviceId !== null)).toBe(true)
813+
const [call] = await db.toolCalls(chatId)
814+
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
815+
expect(proxy.seen(issuedAt, '/api/desktop/tool/authorize')).toEqual([])
816+
})
817+
818+
test('in the background, a result reported across a network cut reaches the agent exactly once', async () => {
819+
proxy.rewriteAnswer(REGISTRATION_PATH, undefined)
820+
const user = await db.seedUser(['Cut chat'])
821+
const chatId = user.chats['Cut chat']
822+
const marker = generateId()
823+
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
824+
let callId = ''
825+
agent.script('[network-cut]', (turn) => {
826+
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
827+
turn.pause()
828+
})
829+
const page = await openApp(user, 'Cut chat')
830+
// Reaches Sim on release although the cut made the app give up on it, as a report already on
831+
// the wire would: the app cannot know it landed, so it reports again once back online.
832+
const completion = proxy.hold(isDesktopCompletion, { deliverIfAbandoned: true })
833+
await send(page, '[network-cut] read my notes')
834+
await completion.arrival(ARRIVAL_MS, 'The result report')
835+
836+
proxy.cutNetwork()
837+
await expect.poll(() => completion.isAbandoned, { timeout: 15_000 }).toBe(true)
838+
completion.release()
839+
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^completed/)
840+
await sleep(5_000)
841+
const restoredAt = Date.now()
842+
proxy.restoreNetwork()
843+
844+
// Back online, the app reopens its doorbell and reports the result again.
845+
await expect
846+
.poll(() => proxy.seen(restoredAt, '/api/desktop/inbox/stream').length, { timeout: 60_000 })
847+
.toBeGreaterThan(0)
848+
const retried = () =>
849+
proxy
850+
.seen(restoredAt)
851+
.filter((entry) => isDesktopCompletion(entry.method, entry.path) && entry.status)
852+
await expect.poll(() => retried().length, { timeout: 60_000 }).toBeGreaterThan(0)
853+
for (const entry of retried()) expect(entry.status).toBeLessThan(300)
854+
855+
await agent.waitForResume(() => Boolean(agent.resultFor(callId)), 60_000)
856+
await proxy.settled(30_000)
857+
const delivered = agent.resumes.filter((resume) =>
858+
resume.results.some((entry) => entry.callId === callId)
859+
)
860+
expect(delivered).toHaveLength(1)
861+
expect(JSON.stringify(agent.resultFor(callId)?.data)).toContain(marker)
862+
const [call] = await db.toolCalls(chatId)
863+
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
864+
})
793865
})

0 commit comments

Comments
 (0)