@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99import '@testing-library/jest-dom'
1010
1111const notifyDesktopMock = vi . hoisted ( ( ) => vi . fn ( ) )
12+ const discardSideChatMock = vi . hoisted ( ( ) => vi . fn ( async ( ) => ( { } ) ) )
1213const sessionsApiMock = vi . hoisted ( ( ) => ( {
1314 delete : vi . fn ( ( ) => Promise . resolve ( ) ) ,
1415 getSlashCommands : vi . fn ( async ( ) => ( { commands : [ ] } ) ) ,
@@ -18,6 +19,7 @@ const sessionsApiMock = vi.hoisted(() => ({
1819} ) )
1920
2021vi . mock ( '../api/sessions' , ( ) => ( { sessionsApi : sessionsApiMock } ) )
22+ vi . mock ( '../api/sideChats' , ( ) => ( { sideChatsApi : { discard : discardSideChatMock } } ) )
2123vi . mock ( '../api/teams' , ( ) => ( { teamsApi : { getWorkbenchForSession : vi . fn ( ) } } ) )
2224vi . mock ( '../lib/desktopNotifications' , ( ) => ( { notifyDesktop : notifyDesktopMock } ) )
2325
@@ -28,6 +30,7 @@ import { registerSideChatSession, unregisterSideChatSession } from '../lib/sideC
2830import { WARM_SESSION_LIMIT , useTabStore , type Tab } from './tabStore'
2931import { useTeamStore } from './teamStore'
3032import { useSessionStore } from './sessionStore'
33+ import { useSideChatStore } from './sideChatStore'
3134import { navigateMobileUp } from '../components/mobile/mobileNavigation'
3235import type { ServerMessage } from '../types/chat'
3336
@@ -173,6 +176,8 @@ describe('releasing sessions that no tab shows (#1485)', () => {
173176 wsManager . disconnectAll ( )
174177 disconnectSpy = vi . spyOn ( wsManager , 'disconnect' )
175178 notifyDesktopMock . mockClear ( )
179+ discardSideChatMock . mockClear ( )
180+ useSideChatStore . setState ( { entries : { } } )
176181 uninstall = installHiddenSessionRelease ( useChatStore )
177182
178183 class ResizeObserverMock {
@@ -427,28 +432,64 @@ describe('releasing sessions that no tab shows (#1485)', () => {
427432 expect ( isHeld ( 'source' ) ) . toBe ( false )
428433 } )
429434
430- it ( 'releases a side chat with its parent once both are done, keeping its transcript' , async ( ) => {
435+ it . each ( [
436+ { scope : 'open-tabs' as const , finishesWhileHidden : false } ,
437+ { scope : 'open-tabs' as const , finishesWhileHidden : true } ,
438+ { scope : 'active-tab' as const , finishesWhileHidden : false } ,
439+ { scope : 'active-tab' as const , finishesWhileHidden : true } ,
440+ ] ) ( 'keeps a temporary side chat usable until explicitly closed ($scope, finishes while hidden: $finishesWhileHidden)' , async ( { scope, finishesWhileHidden } ) => {
441+ uninstall ( )
442+ uninstall = installHiddenSessionRelease ( useChatStore , { scope } )
431443 registerSideChatSession ( 'side-of-parent' , 'parent' )
432444 try {
445+ useSideChatStore . setState ( { entries : {
446+ 'side-of-parent' : { sessionId : 'side-of-parent' , parentSessionId : 'parent' , workDir : '/repo' , title : 'Side chat' , ephemeral : true } ,
447+ } } )
433448 useTabStore . setState ( { tabs : [ sessionTab ( 'parent' , 'Parent chat' ) ] , activeTabId : 'parent' } )
434449 const parentSocket = connect ( 'parent' )
435450 const sideSocket = connect ( 'side-of-parent' )
436451 serverSays ( parentSocket , TURN_COMPLETE )
452+ serverSays ( sideSocket , cliRun ( 'running' ) )
437453 serverSays ( sideSocket , { type : 'status' , state : 'thinking' } )
454+ for ( const message of replyText ( 'Side chat context.' ) ) serverSays ( sideSocket , message )
455+ if ( ! finishesWhileHidden ) {
456+ serverSays ( sideSocket , TURN_COMPLETE )
457+ serverSays ( sideSocket , cliRun ( 'idle' ) )
458+ }
438459 await settle ( )
439- act ( ( ) => { vi . advanceTimersByTime ( EIGHTEEN_HOURS_MS ) } )
440- // While the parent is in the warm set its workspace, side chat included, stays.
441- expect ( isHeld ( 'side-of-parent' ) ) . toBe ( true )
442460
443461 moveOnFrom ( 'parent' )
444462 await settle ( )
445463 expect ( isHeld ( 'parent' ) ) . toBe ( false )
446- // Still answering: it finishes first.
464+ if ( finishesWhileHidden ) {
465+ serverSays ( sideSocket , TURN_COMPLETE )
466+ serverSays ( sideSocket , cliRun ( 'idle' ) )
467+ }
468+ await settle ( )
469+ // #1513 released this socket when the parent left the warm set. The
470+ // server then destroyed the non-persistent CLI after its idle grace,
471+ // and a later send failed with SESSION_DELETED despite cached messages.
472+ act ( ( ) => { vi . advanceTimersByTime ( EIGHTEEN_HOURS_MS ) } )
473+ await settle ( )
474+ expect ( sideSocket . closedByClient ) . toBe ( false )
447475 expect ( isHeld ( 'side-of-parent' ) ) . toBe ( true )
476+ expect ( disconnectSpy ) . not . toHaveBeenCalledWith ( 'side-of-parent' )
448477
478+ act ( ( ) => useTabStore . getState ( ) . openTab ( 'parent' , 'Parent chat' ) )
479+ act ( ( ) => useChatStore . getState ( ) . sendMessage ( 'side-of-parent' , 'Continue the side chat' ) )
480+ expect ( socketsFor ( 'side-of-parent' ) ) . toEqual ( [ sideSocket ] )
481+ expect ( sideSocket . sent ) . toContainEqual ( expect . objectContaining ( { type : 'user_message' , content : 'Continue the side chat' } ) )
482+ expect ( useChatStore . getState ( ) . sessions [ 'side-of-parent' ] ?. messages ) . toContainEqual ( expect . objectContaining ( { type : 'assistant_text' , content : 'Side chat context.' } ) )
449483 serverSays ( sideSocket , TURN_COMPLETE )
484+ serverSays ( sideSocket , cliRun ( 'idle' ) )
450485 await settle ( )
451- expectReleasedWithTranscript ( 'side-of-parent' )
486+
487+ await act ( async ( ) => { await useSideChatStore . getState ( ) . close ( 'side-of-parent' ) } )
488+ expect ( discardSideChatMock ) . toHaveBeenCalledWith ( 'parent' , 'side-of-parent' )
489+ expect ( sideSocket . closedByClient ) . toBe ( true )
490+ expect ( isHeld ( 'side-of-parent' ) ) . toBe ( false )
491+ expect ( useChatStore . getState ( ) . sessions [ 'side-of-parent' ] ) . toBeUndefined ( )
492+ expect ( useSideChatStore . getState ( ) . entries [ 'side-of-parent' ] ) . toBeUndefined ( )
452493 } finally {
453494 unregisterSideChatSession ( 'side-of-parent' )
454495 }
0 commit comments