@@ -13,7 +13,6 @@ import {
1313import type { SimDesktopApi } from '@sim/desktop-bridge'
1414import { sleep } from '@sim/utils/helpers'
1515import { generateId } from '@sim/utils/id'
16- import { toRecord } from '@sim/utils/object'
1716import {
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
3539const DESKTOP_DIR = fileURLToPath ( new URL ( '..' , import . meta. url ) )
@@ -43,6 +47,11 @@ const LEASE_MS = 60_000
4347const ARRIVAL_MS = 60_000
4448/** First requests to a route compile it, which takes minutes on a cold dev app. */
4549const 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
4756type 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 ( / ^ c o m p l e t e d / )
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