@@ -11,6 +11,7 @@ import {
1111 test ,
1212} from '@playwright/test'
1313import type { SimDesktopApi } from '@sim/desktop-bridge'
14+ import { sleep } from '@sim/utils/helpers'
1415import { generateId } from '@sim/utils/id'
1516import { toRecord } from '@sim/utils/object'
1617import {
@@ -34,6 +35,10 @@ import {
3435const DESKTOP_DIR = fileURLToPath ( new URL ( '..' , import . meta. url ) )
3536const config = liveSimConfig ( )
3637const PICKUP_GRACE_MS = 15_000
38+ /** Longer than the default tool budget (60 s) plus the resume grace (30 s). */
39+ const LONG_IMPORT_MS = 120_000
40+ /** The execution lease a running import holds and renews (`SIM_TOOL_EXECUTION_LEASE_SECONDS`). */
41+ const LEASE_MS = 60_000
3742/** How long a held request may take to arrive once the step that sends it ran. */
3843const ARRIVAL_MS = 60_000
3944/** First requests to a route compile it, which takes minutes on a cold dev app. */
@@ -472,6 +477,69 @@ test.describe('desktop tools against a live Sim', () => {
472477 expect ( await db . workspaceFileNames ( user . workspaceId ) ) . toEqual ( [ 'a.txt' ] )
473478 } )
474479
480+ /** An import whose first upload the proxy holds until released, as a large file's would take. */
481+ async function slowImport ( user : SeededUser , title : string , marker : string ) {
482+ const source = importSource ( )
483+ let callId = ''
484+ agent . script ( marker , ( turn ) => {
485+ callId = turn . toolCall ( {
486+ toolName : 'import_local_files' ,
487+ args : { path : source , targetWorkspaceId : user . workspaceId } ,
488+ } )
489+ turn . pause ( )
490+ } )
491+ const page = await openApp ( user , title )
492+ const firstUpload = proxy . hold ( isUploadStart )
493+ await send ( page , `${ marker } import my reports` )
494+ await firstUpload . arrival ( ARRIVAL_MS , 'The first upload' )
495+ return { page, firstUpload, callId : ( ) => callId }
496+ }
497+
498+ test ( 'an import that runs longer than the default tool budget still completes' , async ( ) => {
499+ test . setTimeout ( 420_000 )
500+ const user = await db . seedUser ( [ 'Long import chat' , 'Other chat' ] )
501+ const chatId = user . chats [ 'Long import chat' ]
502+ const { page, firstUpload, callId } = await slowImport (
503+ user ,
504+ 'Long import chat' ,
505+ '[long-import]'
506+ )
507+ // The import is alive and working past the 60 s default budget and its 30 s grace, while the
508+ // user is in another chat: its lease is renewed by the import, not by the chat view.
509+ await openChat ( page , user , 'Other chat' )
510+ await page . waitForTimeout ( LONG_IMPORT_MS )
511+ expect ( agent . resultFor ( callId ( ) ) ) . toBeUndefined ( )
512+ firstUpload . release ( )
513+
514+ await agent . waitForResume ( ( ) => Boolean ( agent . resultFor ( callId ( ) ) ) , 120_000 )
515+ const result = agent . resultFor ( callId ( ) )
516+ expect ( JSON . stringify ( result ?. data ) ) . not . toContain ( 'outcomeUnknown' )
517+ expect ( result ?. success ) . toBe ( true )
518+ await expect
519+ . poll ( ( ) => db . workspaceFileNames ( user . workspaceId ) , { timeout : 30_000 } )
520+ . toEqual ( [ 'a.txt' , 'b.txt' ] )
521+ expect ( await callState ( chatId ) ) . toMatch ( / ^ c o m p l e t e d / )
522+ } )
523+
524+ test ( 'a long import whose window crashed settles as outcome unknown about one lease later' , async ( ) => {
525+ test . setTimeout ( 420_000 )
526+ const user = await db . seedUser ( [ 'Crashing import chat' ] )
527+ const { callId } = await slowImport ( user , 'Crashing import chat' , '[crash-import]' )
528+ await sleep ( LONG_IMPORT_MS )
529+ expect ( agent . resultFor ( callId ( ) ) ) . toBeUndefined ( )
530+ // A crash reports nothing on its way out, unlike a closed window: only the lapse of the
531+ // lease the page was renewing tells Sim the import is gone.
532+ const crashedAt = Date . now ( )
533+ await app ?. evaluate ( ( { webContents } ) => {
534+ for ( const contents of webContents . getAllWebContents ( ) )
535+ if ( contents . getURL ( ) . includes ( '/workspace/' ) ) contents . forcefullyCrashRenderer ( )
536+ } )
537+ await agent . waitForResume ( ( ) => Boolean ( agent . resultFor ( callId ( ) ) ) , 150_000 )
538+ const result = agent . resultFor ( callId ( ) )
539+ expect ( result ?. data ) . toMatchObject ( { outcomeUnknown : true } )
540+ expect ( ( result ?. at ?? 0 ) - crashedAt ) . toBeLessThan ( LEASE_MS + 20_000 )
541+ } )
542+
475543 test ( 'signing out ends a desktop tool still running' , async ( ) => {
476544 const user = await db . seedUser ( [ 'Import chat' ] )
477545 const chatId = user . chats [ 'Import chat' ]
0 commit comments