2026-04-04 01:21:04 +09:00
/// <reference path="../../../bun-test.d.ts" />
2026-04-03 23:06:35 +09:00
import { describe , test , expect , mock , beforeEach , spyOn , afterAll } from 'bun:test'
2026-01-25 15:34:10 +09:00
import type { TmuxConfig } from '../../config/schema'
2026-01-26 12:02:37 +09:00
import type { WindowState , PaneAction } from './types'
2026-01-26 15:11:16 +09:00
import type { ActionResult , ExecuteContext } from './action-executor'
2026-01-30 10:59:54 +09:00
import type { TmuxUtilDeps } from './manager'
2026-02-21 05:59:30 +09:00
import * as sharedModule from '../../shared'
2026-01-25 15:34:10 +09:00
2026-01-26 12:02:37 +09:00
type ExecuteActionsResult = {
success : boolean
spawnedPaneId? : string
results : Array < { action : PaneAction ; result : ActionResult } >
}
2026-03-31 16:58:45 -07:00
type SpawnTmuxContainerResult = {
success : boolean
paneId? : string
}
2026-01-26 12:02:37 +09:00
const mockQueryWindowState = mock < ( paneId : string ) = > Promise < WindowState | null > > (
async ( ) = > ( {
2026-01-26 15:11:16 +09:00
windowWidth : 212 ,
windowHeight : 44 ,
mainPane : { paneId : '%0' , width : 106 , height : 44 , left : 0 , top : 0 , title : 'main' , isActive : true } ,
2026-01-26 12:02:37 +09:00
agentPanes : [ ] ,
} )
)
const mockPaneExists = mock < ( paneId : string ) = > Promise < boolean > > ( async ( ) = > true )
const mockExecuteActions = mock < (
actions : PaneAction [ ] ,
2026-01-26 15:11:16 +09:00
ctx : ExecuteContext
2026-01-26 12:02:37 +09:00
) = > Promise < ExecuteActionsResult > > ( async ( ) = > ( {
success : true ,
spawnedPaneId : '%mock' ,
results : [ ] ,
} ) )
const mockExecuteAction = mock < (
action : PaneAction ,
2026-01-26 15:11:16 +09:00
ctx : ExecuteContext
2026-01-26 12:02:37 +09:00
) = > Promise < ActionResult > > ( async ( ) = > ( { success : true } ) )
2026-03-31 16:58:45 -07:00
const mockSpawnTmuxWindow = mock < (
sessionId : string ,
description : string ,
config : TmuxConfig ,
serverUrl : string
) = > Promise < SpawnTmuxContainerResult > > ( async ( ) = > ( {
success : true ,
paneId : '%isolated-window' ,
} ) )
const mockSpawnTmuxSession = mock < (
sessionId : string ,
description : string ,
config : TmuxConfig ,
serverUrl : string ,
sourcePaneId? : string
) = > Promise < SpawnTmuxContainerResult > > ( async ( ) = > ( {
success : true ,
paneId : '%isolated-session' ,
} ) )
2026-04-18 19:31:45 +09:00
const mockKillTmuxSessionIfExists = mock < ( sessionName : string ) = > Promise < boolean > > ( async ( ) = > true )
2026-01-26 12:02:37 +09:00
const mockIsInsideTmux = mock < ( ) = > boolean > ( ( ) = > true )
const mockGetCurrentPaneId = mock < ( ) = > string | undefined > ( ( ) = > '%0' )
2026-01-30 10:59:54 +09:00
const mockTmuxDeps : TmuxUtilDeps = {
isInsideTmux : mockIsInsideTmux ,
getCurrentPaneId : mockGetCurrentPaneId ,
}
2026-01-26 12:02:37 +09:00
mock . module ( './pane-state-querier' , ( ) = > ( {
queryWindowState : mockQueryWindowState ,
paneExists : mockPaneExists ,
getRightmostAgentPane : ( state : WindowState ) = >
state . agentPanes . length > 0
? state . agentPanes . reduce ( ( r , p ) = > ( p . left > r . left ? p : r ) )
: null ,
getOldestAgentPane : ( state : WindowState ) = >
state . agentPanes . length > 0
? state . agentPanes . reduce ( ( o , p ) = > ( p . left < o . left ? p : o ) )
: null ,
} ) )
2026-04-03 23:06:35 +09:00
afterAll ( ( ) = > { mock . restore ( ) } )
2026-01-26 12:02:37 +09:00
mock . module ( './action-executor' , ( ) = > ( {
executeActions : mockExecuteActions ,
executeAction : mockExecuteAction ,
2026-02-17 01:36:01 +09:00
executeActionWithDeps : mockExecuteAction ,
2026-01-26 12:02:37 +09:00
} ) )
2026-01-25 15:34:10 +09:00
2026-01-30 10:59:54 +09:00
mock . module ( '../../shared/tmux' , ( ) = > {
const { isInsideTmux , getCurrentPaneId } = require ( '../../shared/tmux/tmux-utils' )
const { POLL_INTERVAL_BACKGROUND_MS , SESSION_TIMEOUT_MS , SESSION_MISSING_GRACE_MS } = require ( '../../shared/tmux/constants' )
return {
isInsideTmux ,
getCurrentPaneId ,
POLL_INTERVAL_BACKGROUND_MS ,
SESSION_TIMEOUT_MS ,
SESSION_MISSING_GRACE_MS ,
SESSION_READY_POLL_INTERVAL_MS : 100 ,
SESSION_READY_TIMEOUT_MS : 500 ,
2026-03-31 16:58:45 -07:00
spawnTmuxWindow : mockSpawnTmuxWindow ,
spawnTmuxSession : mockSpawnTmuxSession ,
2026-04-18 19:31:45 +09:00
killTmuxSessionIfExists : mockKillTmuxSessionIfExists ,
2026-04-18 19:47:36 +09:00
getIsolatedSessionName : ( pid : number = 12345 ) = > ` omo-agents- ${ pid } ` ,
2026-01-30 10:59:54 +09:00
}
} )
2026-01-25 15:34:10 +09:00
2026-01-26 12:02:37 +09:00
const trackedSessions = new Set < string > ( )
2026-01-25 15:34:10 +09:00
function createMockContext ( overrides ? : {
sessionStatusResult ? : { data? : Record < string , { type : string } > }
2026-02-01 11:11:35 +01:00
sessionMessagesResult ? : { data? : unknown [ ] }
2026-01-25 15:34:10 +09:00
} ) {
return {
serverUrl : new URL ( 'http://localhost:4096' ) ,
client : {
session : {
2026-01-26 12:02:37 +09:00
status : mock ( async ( ) = > {
if ( overrides ? . sessionStatusResult ) {
return overrides . sessionStatusResult
}
const data : Record < string , { type : string } > = { }
for ( const sessionId of trackedSessions ) {
data [ sessionId ] = { type : 'running' }
}
return { data }
} ) ,
2026-02-01 11:11:35 +01:00
messages : mock ( async ( ) = > {
if ( overrides ? . sessionMessagesResult ) {
return overrides . sessionMessagesResult
}
return { data : [ ] }
} ) ,
2026-01-25 15:34:10 +09:00
} ,
} ,
} as any
}
2026-01-26 12:02:37 +09:00
function createSessionCreatedEvent (
id : string ,
parentID : string | undefined ,
title : string
) {
return {
type : 'session.created' ,
properties : {
info : { id , parentID , title } ,
} ,
}
}
function createWindowState ( overrides? : Partial < WindowState > ) : WindowState {
return {
2026-01-26 15:25:05 +09:00
windowWidth : 220 ,
2026-01-26 15:11:16 +09:00
windowHeight : 44 ,
2026-01-26 15:25:05 +09:00
mainPane : { paneId : '%0' , width : 110 , height : 44 , left : 0 , top : 0 , title : 'main' , isActive : true } ,
2026-01-26 12:02:37 +09:00
agentPanes : [ ] ,
. . . overrides ,
}
}
2026-04-04 01:21:04 +09:00
function createTmuxConfig ( overrides? : Partial < TmuxConfig > ) : TmuxConfig {
return {
enabled : true ,
isolation : 'inline' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 ,
. . . overrides ,
}
}
2026-04-04 15:40:26 +09:00
function getTrackedSessions ( manager : object ) : Map < string , { paneId : string ; closePending : boolean ; closeRetryCount : number } > {
return Reflect . get ( manager , 'sessions' ) as Map < string , { paneId : string ; closePending : boolean ; closeRetryCount : number } >
}
2026-01-25 15:34:10 +09:00
describe ( 'TmuxSessionManager' , ( ) = > {
beforeEach ( ( ) = > {
2026-01-26 12:02:37 +09:00
mockQueryWindowState . mockClear ( )
mockPaneExists . mockClear ( )
mockExecuteActions . mockClear ( )
mockExecuteAction . mockClear ( )
2026-03-31 16:58:45 -07:00
mockSpawnTmuxWindow . mockClear ( )
mockSpawnTmuxSession . mockClear ( )
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockClear ( )
2026-01-26 12:02:37 +09:00
mockGetCurrentPaneId . mockClear ( )
trackedSessions . clear ( )
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
2026-04-04 01:21:04 +09:00
mockExecuteActions . mockImplementation ( async ( actions : PaneAction [ ] ) = > { for ( const action of actions ) {
if ( action . type === 'spawn' ) {
trackedSessions . add ( action . sessionId )
2026-01-26 12:02:37 +09:00
}
2026-04-04 01:21:04 +09:00
}
return {
success : true ,
spawnedPaneId : '%mock' ,
results : [ ] ,
} } )
mockSpawnTmuxWindow . mockImplementation ( async ( sessionId : string ) = > {
2026-03-31 16:58:45 -07:00
trackedSessions . add ( sessionId )
return {
success : true ,
paneId : ` %isolated-window- ${ sessionId } ` ,
}
} )
2026-04-04 01:21:04 +09:00
mockSpawnTmuxSession . mockImplementation ( async ( sessionId : string ) = > {
2026-03-31 16:58:45 -07:00
trackedSessions . add ( sessionId )
return {
success : true ,
paneId : ` %isolated-session- ${ sessionId } ` ,
}
} )
2026-01-25 15:34:10 +09:00
} )
describe ( 'constructor' , ( ) = > {
test ( 'enabled when config.enabled=true and isInsideTmux=true' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
2026-02-15 03:26:39 +08:00
const ctx = createMockContext ( {
sessionStatusResult : {
data : {
ses_1 : { type : 'running' } ,
ses_2 : { type : 'running' } ,
ses_3 : { type : 'running' } ,
} ,
} ,
} )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-25 15:34:10 +09:00
expect ( manager ) . toBeDefined ( )
} )
test ( 'disabled when config.enabled=true but isInsideTmux=false' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( false )
const { TmuxSessionManager } = await import ( './manager' )
2026-02-15 03:26:39 +08:00
const ctx = createMockContext ( {
sessionStatusResult : {
data : {
ses_once : { type : 'running' } ,
} ,
} ,
} )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-25 15:34:10 +09:00
expect ( manager ) . toBeDefined ( )
} )
test ( 'disabled when config.enabled=false' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : false ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-25 15:34:10 +09:00
expect ( manager ) . toBeDefined ( )
} )
2026-03-23 18:09:31 +09:00
test ( 'falls back to default port when serverUrl has port 0' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = {
. . . createMockContext ( ) ,
serverUrl : new URL ( 'http://127.0.0.1:0/' ) ,
}
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-03-23 18:09:31 +09:00
// when
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// then
expect ( ( manager as any ) . serverUrl ) . toBe ( 'http://localhost:4096' )
} )
2026-01-25 15:34:10 +09:00
} )
describe ( 'onSessionCreated' , ( ) = > {
2026-01-26 12:02:37 +09:00
test ( 'first agent spawns from source pane via decision engine' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
2026-01-26 12:02:37 +09:00
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
2026-01-25 15:34:10 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-26 12:02:37 +09:00
const event = createSessionCreatedEvent (
'ses_child' ,
'ses_parent' ,
'Background: Test Task'
)
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated ( event )
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockQueryWindowState ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
const call = mockExecuteActions . mock . calls [ 0 ]
expect ( call ) . toBeDefined ( )
const actionsArg = call ! [ 0 ]
expect ( actionsArg ) . toHaveLength ( 1 )
2026-01-26 15:11:16 +09:00
expect ( actionsArg [ 0 ] . type ) . toBe ( 'spawn' )
if ( actionsArg [ 0 ] . type === 'spawn' ) {
expect ( actionsArg [ 0 ] . sessionId ) . toBe ( 'ses_child' )
expect ( actionsArg [ 0 ] . description ) . toBe ( 'Background: Test Task' )
expect ( actionsArg [ 0 ] . targetPaneId ) . toBe ( '%0' )
expect ( actionsArg [ 0 ] . splitDirection ) . toBe ( '-h' )
}
2026-01-26 12:02:37 +09:00
} )
2026-01-26 15:11:16 +09:00
test ( 'second agent spawns with correct split direction' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
mockIsInsideTmux . mockReturnValue ( true )
let callCount = 0
mockQueryWindowState . mockImplementation ( async ( ) = > {
callCount ++
if ( callCount === 1 ) {
return createWindowState ( )
}
return createWindowState ( {
agentPanes : [
{
paneId : '%1' ,
width : 40 ,
2026-01-26 15:11:16 +09:00
height : 44 ,
left : 100 ,
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'omo-subagent-Task 1' ,
isActive : false ,
} ,
] ,
} )
} )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when - first agent
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_1' , 'ses_parent' , 'Task 1' )
)
mockExecuteActions . mockClear ( )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when - second agent
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_2' , 'ses_parent' , 'Task 2' )
2026-01-25 15:34:10 +09:00
)
2026-01-26 12:02:37 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
const call = mockExecuteActions . mock . calls [ 0 ]
expect ( call ) . toBeDefined ( )
const actionsArg = call ! [ 0 ]
expect ( actionsArg ) . toHaveLength ( 1 )
2026-01-26 15:11:16 +09:00
expect ( actionsArg [ 0 ] . type ) . toBe ( 'spawn' )
2026-01-25 15:34:10 +09:00
} )
2026-03-31 16:58:45 -07:00
test ( '#given session isolation with healthy existing container #when second subagent is created #then it spawns inline from isolated pane' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
2026-04-04 01:21:04 +09:00
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} )
}
return createWindowState ( ) } )
2026-03-31 16:58:45 -07:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-03-31 16:58:45 -07:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
mockExecuteActions . mockClear ( )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' )
)
// then
expect ( mockSpawnTmuxSession ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
const executeActionsCall = mockExecuteActions . mock . calls [ 0 ]
expect ( executeActionsCall ) . toBeDefined ( )
const actions = executeActionsCall ? . [ 0 ]
const context = executeActionsCall ? . [ 1 ]
expect ( actions ) . toBeDefined ( )
expect ( actions ) . toHaveLength ( 1 )
expect ( actions ? . [ 0 ] ? . type ) . toBe ( 'spawn' )
if ( actions ? . [ 0 ] ? . type === 'spawn' ) {
expect ( actions [ 0 ] . sessionId ) . toBe ( 'ses_second' )
expect ( actions [ 0 ] . targetPaneId ) . toBe ( '%isolated-session-ses_first' )
}
expect ( context ? . sourcePaneId ) . toBe ( '%isolated-session-ses_first' )
} )
2026-04-04 01:21:04 +09:00
test ( '#given window isolation with healthy existing container #when second subagent is created #then it spawns inline from isolated pane' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
2026-04-04 01:21:04 +09:00
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { if ( paneId === '%isolated-window-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} )
}
return createWindowState ( ) } )
2026-01-25 15:34:10 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
isolation : 'window' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
mockExecuteActions . mockClear ( )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' )
)
// then
expect ( mockSpawnTmuxWindow ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
const executeActionsCall = mockExecuteActions . mock . calls [ 0 ]
expect ( executeActionsCall ) . toBeDefined ( )
const actions = executeActionsCall ? . [ 0 ]
const context = executeActionsCall ? . [ 1 ]
expect ( actions ) . toBeDefined ( )
expect ( actions ) . toHaveLength ( 1 )
expect ( actions ? . [ 0 ] ? . type ) . toBe ( 'spawn' )
if ( actions ? . [ 0 ] ? . type === 'spawn' ) {
expect ( actions [ 0 ] . sessionId ) . toBe ( 'ses_second' )
expect ( actions [ 0 ] . targetPaneId ) . toBe ( '%isolated-window-ses_first' )
2026-01-25 15:34:10 +09:00
}
2026-04-04 01:21:04 +09:00
expect ( context ? . sourcePaneId ) . toBe ( '%isolated-window-ses_first' )
} )
test ( 'does NOT spawn pane when session has no parentID' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-26 12:02:37 +09:00
const event = createSessionCreatedEvent ( 'ses_root' , undefined , 'Root Session' )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-25 15:34:10 +09:00
await manager . onSessionCreated ( event )
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 0 )
2026-01-25 15:34:10 +09:00
} )
test ( 'does NOT spawn pane when disabled' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : false ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-26 12:02:37 +09:00
const event = createSessionCreatedEvent (
'ses_child' ,
'ses_parent' ,
'Background: Test Task'
)
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-25 15:34:10 +09:00
await manager . onSessionCreated ( event )
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 0 )
2026-01-25 15:34:10 +09:00
} )
2026-01-26 12:02:37 +09:00
test ( 'does NOT spawn pane for non session.created event type' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-26 12:02:37 +09:00
const event = {
type : 'session.deleted' ,
properties : {
info : { id : 'ses_child' , parentID : 'ses_parent' , title : 'Task' } ,
} ,
}
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated ( event )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 0 )
2026-01-25 15:34:10 +09:00
} )
2026-02-15 03:26:39 +08:00
test ( 'defers attach when unsplittable (small window)' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - small window where split is not possible
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
2026-01-26 12:02:37 +09:00
mockQueryWindowState . mockImplementation ( async ( ) = >
createWindowState ( {
windowWidth : 160 ,
2026-01-26 15:11:16 +09:00
windowHeight : 11 ,
2026-01-26 12:02:37 +09:00
agentPanes : [
{
paneId : '%1' ,
width : 40 ,
2026-01-26 15:11:16 +09:00
height : 11 ,
left : 80 ,
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'omo-subagent-Task 1' ,
isActive : false ,
} ,
] ,
} )
)
2026-01-25 15:34:10 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_new' , 'ses_parent' , 'New Task' )
)
2026-02-15 03:26:39 +08:00
// then - with small window, manager defers instead of replacing
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 0 )
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_new' ] )
} )
test ( 'keeps deferred queue idempotent for duplicate session.created events' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = >
createWindowState ( {
windowWidth : 160 ,
windowHeight : 11 ,
agentPanes : [
{
paneId : '%1' ,
width : 80 ,
height : 11 ,
left : 80 ,
top : 0 ,
title : 'old' ,
isActive : false ,
} ,
] ,
} )
)
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 , } )
2026-02-15 03:26:39 +08:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_dup' , 'ses_parent' , 'Duplicate Task' )
)
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_dup' , 'ses_parent' , 'Duplicate Task' )
)
// then
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_dup' ] )
} )
test ( 'auto-attaches deferred sessions in FIFO order' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = >
createWindowState ( {
windowWidth : 160 ,
windowHeight : 11 ,
agentPanes : [
{
paneId : '%1' ,
width : 80 ,
height : 11 ,
left : 80 ,
top : 0 ,
title : 'old' ,
isActive : false ,
} ,
] ,
} )
)
const attachOrder : string [ ] = [ ]
2026-04-04 01:21:04 +09:00
mockExecuteActions . mockImplementation ( async ( actions : PaneAction [ ] ) = > { for ( const action of actions ) {
if ( action . type === 'spawn' ) {
attachOrder . push ( action . sessionId )
trackedSessions . add ( action . sessionId )
return {
success : true ,
spawnedPaneId : ` % ${ action . sessionId } ` ,
results : [ { action , result : { success : true , paneId : ` % ${ action . sessionId } ` } } ] ,
2026-02-15 03:26:39 +08:00
}
}
2026-04-04 01:21:04 +09:00
}
return { success : true , results : [ ] } } )
2026-02-15 03:26:39 +08:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 , } )
2026-02-15 03:26:39 +08:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_1' , 'ses_parent' , 'Task 1' ) )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_2' , 'ses_parent' , 'Task 2' ) )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_3' , 'ses_parent' , 'Task 3' ) )
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_1' , 'ses_2' , 'ses_3' ] )
// when
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
await ( manager as any ) . tryAttachDeferredSession ( )
await ( manager as any ) . tryAttachDeferredSession ( )
await ( manager as any ) . tryAttachDeferredSession ( )
// then
expect ( attachOrder ) . toEqual ( [ 'ses_1' , 'ses_2' , 'ses_3' ] )
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ ] )
} )
test ( 'does not attach deferred session more than once across repeated retries' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = >
createWindowState ( {
windowWidth : 160 ,
windowHeight : 11 ,
agentPanes : [
{
paneId : '%1' ,
width : 80 ,
height : 11 ,
left : 80 ,
top : 0 ,
title : 'old' ,
isActive : false ,
} ,
] ,
} )
)
let attachCount = 0
2026-04-04 01:21:04 +09:00
mockExecuteActions . mockImplementation ( async ( actions : PaneAction [ ] ) = > { for ( const action of actions ) {
if ( action . type === 'spawn' ) {
attachCount += 1
trackedSessions . add ( action . sessionId )
return {
success : true ,
spawnedPaneId : ` % ${ action . sessionId } ` ,
results : [ { action , result : { success : true , paneId : ` % ${ action . sessionId } ` } } ] ,
2026-02-15 03:26:39 +08:00
}
}
2026-04-04 01:21:04 +09:00
}
return { success : true , results : [ ] } } )
2026-02-15 03:26:39 +08:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 , } )
2026-02-15 03:26:39 +08:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_once' , 'ses_parent' , 'Task Once' )
)
// when
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
await ( manager as any ) . tryAttachDeferredSession ( )
await ( manager as any ) . tryAttachDeferredSession ( )
// then
expect ( attachCount ) . toBe ( 1 )
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ ] )
} )
test ( 'removes deferred session when session is deleted before attach' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = >
createWindowState ( {
windowWidth : 160 ,
windowHeight : 11 ,
agentPanes : [
{
paneId : '%1' ,
width : 80 ,
height : 11 ,
left : 80 ,
top : 0 ,
title : 'old' ,
isActive : false ,
} ,
] ,
} )
)
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 , } )
2026-02-15 03:26:39 +08:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_pending' , 'ses_parent' , 'Pending Task' )
)
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_pending' ] )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_pending' } )
// then
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ ] )
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 0 )
2026-01-25 15:34:10 +09:00
} )
2026-02-21 05:59:30 +09:00
describe ( 'spawn failure recovery' , ( ) = > {
2026-04-04 01:21:04 +09:00
test ( '#given the first isolated container spawn fails #when onSessionCreated fires #then the session is deferred for retry' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockSpawnTmuxSession . mockImplementation ( async ( ) = > ( {
success : false ,
} ) )
const logSpy = spyOn ( sharedModule , 'log' ) . mockImplementation ( ( ) = > { } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_isolated_fail' , 'ses_parent' , 'Isolated Failure Task' )
)
// then
expect ( mockSpawnTmuxSession ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 0 )
expect ( Reflect . get ( manager , 'deferredQueue' ) ) . toEqual ( [ 'ses_isolated_fail' ] )
logSpy . mockRestore ( )
} )
2026-04-04 02:38:15 +09:00
test ( '#given an isolated session deferred after container spawn failure #when deferred attach retries #then it re-attempts isolated container creation before normal pane fallback' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockSpawnTmuxSession . mockImplementation ( async ( ) = > ( {
success : false ,
} ) )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_isolated_retry' , 'ses_parent' , 'Isolated Retry Task' )
)
mockExecuteActions . mockClear ( )
// when
await Reflect . get ( manager , 'tryAttachDeferredSession' ) . call ( manager )
// then
expect ( mockSpawnTmuxSession ) . toHaveBeenCalledTimes ( 2 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteActions . mock . calls [ 0 ] ? . [ 1 ] ? . sourcePaneId ) . toBe ( '%0' )
} )
2026-02-21 05:59:30 +09:00
test ( '#given queryWindowState returns null #when onSessionCreated fires #then session is enqueued in deferred queue' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = > null )
const logSpy = spyOn ( sharedModule , 'log' ) . mockImplementation ( ( ) = > { } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-02-21 05:59:30 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_null_state' , 'ses_parent' , 'Null State Task' )
)
// then
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_null_state' ] )
logSpy . mockRestore ( )
} )
2026-04-04 01:21:04 +09:00
test ( '#given isolated window state returns one transient null #when another subagent is created #then the existing container is reused' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
const isolatedPaneId = '%isolated-session-ses_first'
let isolatedPaneQueryCount = 0
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { if ( paneId === isolatedPaneId ) {
isolatedPaneQueryCount += 1
if ( isolatedPaneQueryCount === 1 ) {
return null
}
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} )
}
return createWindowState ( ) } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
mockSpawnTmuxSession . mockClear ( )
mockExecuteActions . mockClear ( )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' )
)
// then
expect ( mockSpawnTmuxSession ) . toHaveBeenCalledTimes ( 0 )
expect ( mockExecuteActions ) . toHaveBeenCalledTimes ( 1 )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( isolatedPaneId )
expect ( mockExecuteActions . mock . calls [ 0 ] ? . [ 1 ] ? . sourcePaneId ) . toBe ( isolatedPaneId )
} )
2026-02-21 05:59:30 +09:00
test ( '#given spawn fails without close action #when onSessionCreated fires #then session is enqueued in deferred queue' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
2026-04-04 01:21:04 +09:00
mockExecuteActions . mockImplementation ( async ( actions : PaneAction [ ] ) = > ( {
2026-02-21 05:59:30 +09:00
success : false ,
spawnedPaneId : undefined ,
2026-04-04 01:21:04 +09:00
results : actions.map ( ( action : PaneAction ) = > ( {
2026-02-21 05:59:30 +09:00
action ,
result : { success : false , error : 'spawn failed' } ,
} ) ) ,
} ) )
const logSpy = spyOn ( sharedModule , 'log' ) . mockImplementation ( ( ) = > { } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-02-21 05:59:30 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_fail_no_close' , 'ses_parent' , 'Spawn Fail No Close' )
)
// then
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_fail_no_close' ] )
logSpy . mockRestore ( )
} )
test ( '#given spawn fails with close action that succeeded #when onSessionCreated fires #then session is still enqueued in deferred queue' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
mockExecuteActions . mockImplementation ( async ( ) = > ( {
success : false ,
spawnedPaneId : undefined ,
results : [
{
action : { type : 'close' , paneId : '%1' , sessionId : 'ses_old' } ,
result : { success : true } ,
} ,
{
action : {
type : 'spawn' ,
sessionId : 'ses_fail_with_close' ,
description : 'Spawn Fail With Close' ,
targetPaneId : '%0' ,
splitDirection : '-h' ,
} ,
result : { success : false , error : 'spawn failed after close' } ,
} ,
] ,
} ) )
const logSpy = spyOn ( sharedModule , 'log' ) . mockImplementation ( ( ) = > { } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-02-21 05:59:30 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
// when
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_fail_with_close' , 'ses_parent' , 'Spawn Fail With Close' )
)
// then
expect ( ( manager as any ) . deferredQueue ) . toEqual ( [ 'ses_fail_with_close' ] )
logSpy . mockRestore ( )
} )
} )
2026-04-18 17:22:01 +09:00
test ( '#given session.status never reports session ready #when onSessionCreated runs #then pane is tracked immediately without blocking' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( ) = > createWindowState ( ) )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( { sessionStatusResult : { data : { } } } )
const config = createTmuxConfig ( { enabled : true } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
const event = createSessionCreatedEvent ( 'ses_fast_track' , 'ses_parent' , 'Fast Track' )
// when
const start = Date . now ( )
await manager . onSessionCreated ( event )
const elapsed = Date . now ( ) - start
// then
expect ( elapsed < 500 ) . toBe ( true )
expect ( getTrackedSessions ( manager ) . has ( 'ses_fast_track' ) ) . toBe ( true )
} )
2026-01-25 15:34:10 +09:00
} )
2026-01-26 12:02:37 +09:00
describe ( 'onSessionDeleted' , ( ) = > {
2026-02-17 03:40:55 +09:00
test ( 'does not track session when readiness timed out' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
let stateCallCount = 0
mockQueryWindowState . mockImplementation ( async ( ) = > {
stateCallCount ++
if ( stateCallCount === 1 ) {
return createWindowState ( )
}
return createWindowState ( {
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 100 ,
top : 0 ,
title : 'omo-subagent-Timeout Task' ,
isActive : false ,
} ,
] ,
} )
} )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( { sessionStatusResult : { data : { } } } )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-02-17 03:40:55 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_timeout' , 'ses_parent' , 'Timeout Task' )
)
mockExecuteAction . mockClear ( )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_timeout' } )
// then
2026-02-20 07:13:33 +08:00
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 1 )
2026-02-17 03:40:55 +09:00
} )
2026-01-26 12:02:37 +09:00
test ( 'closes pane when tracked session is deleted' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
2026-01-26 12:02:37 +09:00
let stateCallCount = 0
mockQueryWindowState . mockImplementation ( async ( ) = > {
stateCallCount ++
if ( stateCallCount === 1 ) {
return createWindowState ( )
}
return createWindowState ( {
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
2026-01-26 15:11:16 +09:00
height : 44 ,
left : 100 ,
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'omo-subagent-Task' ,
isActive : false ,
} ,
] ,
} )
2026-01-25 15:34:10 +09:00
} )
2026-01-26 12:02:37 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent (
'ses_child' ,
'ses_parent' ,
'Background: Test Task'
)
)
mockExecuteAction . mockClear ( )
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . onSessionDeleted ( { sessionID : 'ses_child' } )
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 1 )
const call = mockExecuteAction . mock . calls [ 0 ]
expect ( call ) . toBeDefined ( )
expect ( call ! [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%mock' ,
sessionId : 'ses_child' ,
2026-01-25 15:34:10 +09:00
} )
2026-01-26 12:02:37 +09:00
} )
2026-01-25 15:34:10 +09:00
2026-04-01 18:18:32 -07:00
test ( '#given session isolation with a spawned container #when the first isolated subagent is deleted #then it cleans up the isolated container and clears the anchor pane id' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
let stateCallCount = 0
2026-04-04 01:21:04 +09:00
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { stateCallCount ++
if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} )
}
if ( stateCallCount === 1 ) {
return createWindowState ( )
}
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} ) } )
2026-04-01 18:18:32 -07:00
2026-04-04 01:21:04 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-04-01 18:18:32 -07:00
2026-04-04 01:21:04 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
mockExecuteAction . mockClear ( )
2026-04-01 18:18:32 -07:00
2026-04-04 01:21:04 +09:00
// when
await manager . onSessionDeleted ( { sessionID : 'ses_first' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_first' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
test ( '#given window isolation with a spawned container #when the first isolated subagent is deleted #then it cleans up the isolated container and clears the anchor pane id' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
let stateCallCount = 0
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { stateCallCount += 1
if ( paneId === '%isolated-window-ses_first' ) {
2026-04-01 18:18:32 -07:00
return createWindowState ( {
mainPane : {
2026-04-04 01:21:04 +09:00
paneId ,
2026-04-01 18:18:32 -07:00
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} )
2026-04-04 01:21:04 +09:00
}
if ( stateCallCount === 1 ) {
return createWindowState ( )
}
return createWindowState ( {
mainPane : {
paneId : '%isolated-window-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
} ) } )
2026-04-01 18:18:32 -07:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
isolation : 'window' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-04-01 18:18:32 -07:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
mockExecuteAction . mockClear ( )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_first' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 1 )
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
2026-04-04 01:21:04 +09:00
paneId : '%isolated-window-ses_first' ,
2026-04-01 18:18:32 -07:00
sessionId : 'ses_first' ,
} )
2026-04-03 18:42:58 +09:00
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
2026-04-01 18:18:32 -07:00
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
2026-04-03 18:12:33 +09:00
test ( '#given session isolation with another subagent still tracked #when the anchor subagent is deleted first #then it reassigns the anchor and cleans up when the last subagent exits' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
2026-04-04 01:21:04 +09:00
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
2026-04-03 18:12:33 +09:00
height : 44 ,
2026-04-04 01:21:04 +09:00
left : 110 ,
2026-04-03 18:12:33 +09:00
top : 0 ,
2026-04-04 01:21:04 +09:00
title : 'omo-subagent-Second Task' ,
isActive : false ,
2026-04-03 18:12:33 +09:00
} ,
2026-04-04 01:21:04 +09:00
] ,
} )
}
if ( paneId === '%mock' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId ,
width : 40 ,
2026-04-03 18:12:33 +09:00
height : 44 ,
2026-04-04 01:21:04 +09:00
left : 110 ,
2026-04-03 18:12:33 +09:00
top : 0 ,
2026-04-04 01:21:04 +09:00
title : 'omo-subagent-Second Task' ,
isActive : false ,
2026-04-03 18:12:33 +09:00
} ,
2026-04-04 01:21:04 +09:00
] ,
} )
}
return createWindowState ( ) } )
2026-04-03 18:12:33 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
isolation : 'session' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-04-03 18:12:33 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' )
)
mockExecuteAction . mockClear ( )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_first' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 0 )
2026-04-03 18:42:58 +09:00
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBe ( '%isolated-session-ses_first' )
2026-04-03 18:12:33 +09:00
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( '%mock' )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_second' } )
// then
2026-04-03 18:42:58 +09:00
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 2 )
2026-04-03 18:12:33 +09:00
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%mock' ,
sessionId : 'ses_second' ,
} )
2026-04-03 18:42:58 +09:00
expect ( mockExecuteAction . mock . calls [ 1 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_second' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
2026-04-03 18:12:33 +09:00
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
2026-04-04 01:21:04 +09:00
test ( '#given window isolation with another subagent still tracked #when the anchor subagent is deleted first #then it reassigns the anchor and cleans up when the last subagent exits' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > { if ( paneId === '%isolated-window-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
if ( paneId === '%mock' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-window-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
return createWindowState ( ) } )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
const config = createTmuxConfig ( { enabled : true ,
isolation : 'window' ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' )
)
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' )
)
mockExecuteAction . mockClear ( )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_first' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 0 )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBe ( '%isolated-window-ses_first' )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( '%mock' )
// when
await manager . onSessionDeleted ( { sessionID : 'ses_second' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 2 )
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%mock' ,
sessionId : 'ses_second' ,
} )
expect ( mockExecuteAction . mock . calls [ 1 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-window-ses_first' ,
sessionId : 'ses_second' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
2026-01-26 12:02:37 +09:00
test ( 'does nothing when untracked session is deleted' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
mockIsInsideTmux . mockReturnValue ( true )
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . onSessionDeleted ( { sessionID : 'ses_unknown' } )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 0 )
2026-01-25 15:34:10 +09:00
} )
} )
describe ( 'cleanup' , ( ) = > {
2026-04-04 15:40:26 +09:00
test ( '#given session isolation with two tracked panes #when polling closes both sessions #then it reassigns the anchor and cleans up the isolated container' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > {
if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
if ( paneId === '%mock' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
return createWindowState ( )
} )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' ) )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' ) )
mockExecuteAction . mockClear ( )
const closeSessionById = Reflect . get ( manager , 'closeSessionById' ) as ( sessionId : string ) = > Promise < void >
// when
await closeSessionById . call ( manager , 'ses_first' )
// then
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_first' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBe ( '%isolated-session-ses_first' )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( '%mock' )
// when
await closeSessionById . call ( manager , 'ses_second' )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 3 )
expect ( mockExecuteAction . mock . calls [ 1 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%mock' ,
sessionId : 'ses_second' ,
} )
expect ( mockExecuteAction . mock . calls [ 2 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_second' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
test ( '#given session isolation with two tracked panes #when process shutdown cleanup runs #then it closes panes and the isolated container through the shared close path' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > {
if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
if ( paneId === '%mock' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
return createWindowState ( )
} )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' ) )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' ) )
mockExecuteAction . mockClear ( )
// when
await manager . cleanup ( )
// then
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 3 )
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_first' ,
} )
expect ( mockExecuteAction . mock . calls [ 1 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%mock' ,
sessionId : 'ses_second' ,
} )
expect ( mockExecuteAction . mock . calls [ 2 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_second' ,
} )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBeUndefined ( )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBeUndefined ( )
} )
test ( '#given an isolated anchor close that fails once #when retryPendingCloses succeeds on retry #then it reassigns the isolated anchor through the shared cleanup path' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockQueryWindowState . mockImplementation ( async ( paneId : string ) = > {
if ( paneId === '%isolated-session-ses_first' ) {
return createWindowState ( {
mainPane : {
paneId : '%isolated-session-ses_first' ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : 'isolated' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%mock' ,
width : 40 ,
height : 44 ,
left : 110 ,
top : 0 ,
title : 'omo-subagent-Second Task' ,
isActive : false ,
} ,
] ,
} )
}
return createWindowState ( )
} )
let closeAttemptCount = 0
mockExecuteAction . mockImplementation ( async ( action : PaneAction ) = > {
if ( action . type === 'close' && action . sessionId === 'ses_first' ) {
closeAttemptCount += 1
if ( closeAttemptCount === 1 ) {
return { success : false }
}
}
return { success : true }
} )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_first' , 'ses_parent' , 'First Task' ) )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_second' , 'ses_parent' , 'Second Task' ) )
mockExecuteAction . mockClear ( )
const closeSessionById = Reflect . get ( manager , 'closeSessionById' ) as ( sessionId : string ) = > Promise < void >
const retryPendingCloses = Reflect . get ( manager , 'retryPendingCloses' ) as ( ) = > Promise < void >
// when
await closeSessionById . call ( manager , 'ses_first' )
// then
expect ( getTrackedSessions ( manager ) . get ( 'ses_first' ) ? . closePending ) . toBe ( true )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( '%isolated-session-ses_first' )
// when
await retryPendingCloses . call ( manager )
// then
expect ( getTrackedSessions ( manager ) . has ( 'ses_first' ) ) . toBe ( false )
expect ( Reflect . get ( manager , 'isolatedContainerPaneId' ) ) . toBe ( '%isolated-session-ses_first' )
expect ( Reflect . get ( manager , 'isolatedWindowPaneId' ) ) . toBe ( '%mock' )
expect ( mockExecuteAction . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_first' ,
} )
expect ( mockExecuteAction . mock . calls [ 1 ] ? . [ 0 ] ) . toEqual ( {
type : 'close' ,
paneId : '%isolated-session-ses_first' ,
sessionId : 'ses_first' ,
} )
} )
2026-01-25 15:34:10 +09:00
test ( 'closes all tracked panes' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-25 15:34:10 +09:00
mockIsInsideTmux . mockReturnValue ( true )
2026-01-26 12:02:37 +09:00
let callCount = 0
2026-04-04 01:21:04 +09:00
mockExecuteActions . mockImplementation ( async ( actions : PaneAction [ ] ) = > { callCount ++
for ( const action of actions ) {
if ( action . type === 'spawn' ) {
trackedSessions . add ( action . sessionId )
2026-01-26 12:02:37 +09:00
}
2026-04-04 01:21:04 +09:00
}
return {
success : true ,
spawnedPaneId : ` % ${ callCount } ` ,
results : [ ] ,
} } )
2026-01-26 12:02:37 +09:00
2026-01-25 15:34:10 +09:00
const { TmuxSessionManager } = await import ( './manager' )
const ctx = createMockContext ( )
2026-04-04 01:21:04 +09:00
const config = createTmuxConfig ( { enabled : true ,
layout : 'main-vertical' ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 , } )
2026-01-30 10:59:54 +09:00
const manager = new TmuxSessionManager ( ctx , config , mockTmuxDeps )
2026-01-25 15:34:10 +09:00
2026-01-26 12:02:37 +09:00
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_1' , 'ses_parent' , 'Task 1' )
)
await manager . onSessionCreated (
createSessionCreatedEvent ( 'ses_2' , 'ses_parent' , 'Task 2' )
)
mockExecuteAction . mockClear ( )
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
await manager . cleanup ( )
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( mockExecuteAction ) . toHaveBeenCalledTimes ( 2 )
} )
2026-04-18 19:31:45 +09:00
2026-04-18 19:47:36 +09:00
test ( '#given tmux isolation is "session" #when cleanup runs #then killTmuxSessionIfExists is invoked for the per-pid isolated session' , async ( ) = > {
2026-04-18 19:31:45 +09:00
// given
mockKillTmuxSessionIfExists . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
// when
await manager . cleanup ( )
// then
expect ( mockKillTmuxSessionIfExists ) . toHaveBeenCalledTimes ( 1 )
2026-04-18 19:47:36 +09:00
expect ( mockKillTmuxSessionIfExists . mock . calls [ 0 ] ? . [ 0 ] ) . toMatch ( /^omo-agents-\d+$/ )
} )
test ( '#given two manager instances #when both cleanup #then each kills its own isolated session name, not a shared one' , async ( ) = > {
// given
mockKillTmuxSessionIfExists . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const managerA = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
const managerB = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
// when
await managerA . cleanup ( )
await managerB . cleanup ( )
// then
expect ( mockKillTmuxSessionIfExists ) . toHaveBeenCalledTimes ( 2 )
const firstTarget = mockKillTmuxSessionIfExists . mock . calls [ 0 ] ? . [ 0 ]
const secondTarget = mockKillTmuxSessionIfExists . mock . calls [ 1 ] ? . [ 0 ]
expect ( firstTarget ) . toMatch ( /^omo-agents-\d+$/ )
expect ( secondTarget ) . toMatch ( /^omo-agents-\d+$/ )
2026-04-18 19:31:45 +09:00
} )
test ( '#given tmux isolation is "inline" #when cleanup runs #then killTmuxSessionIfExists is NOT invoked' , async ( ) = > {
// given
mockKillTmuxSessionIfExists . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'inline' ,
} ) , mockTmuxDeps )
// when
await manager . cleanup ( )
// then
expect ( mockKillTmuxSessionIfExists ) . toHaveBeenCalledTimes ( 0 )
} )
test ( '#given tmux isolation is "window" #when cleanup runs #then killTmuxSessionIfExists is NOT invoked' , async ( ) = > {
// given
mockKillTmuxSessionIfExists . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'window' ,
} ) , mockTmuxDeps )
// when
await manager . cleanup ( )
// then
expect ( mockKillTmuxSessionIfExists ) . toHaveBeenCalledTimes ( 0 )
} )
test ( '#given a tracked session #when onSessionError is invoked #then the pane is closed like onSessionDeleted' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockExecuteAction . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
await manager . onSessionCreated ( createSessionCreatedEvent ( 'ses_err' , 'ses_parent' , 'Errored Task' ) )
mockExecuteAction . mockClear ( )
// when
await manager . onSessionError ( { sessionID : 'ses_err' } )
// then
expect ( mockExecuteAction ) . toHaveBeenCalled ( )
} )
test ( '#given an untracked session #when onSessionError is invoked #then it is a no-op and does not throw' , async ( ) = > {
// given
mockIsInsideTmux . mockReturnValue ( true )
mockExecuteAction . mockClear ( )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
// when
const errorHandler = manager . onSessionError ( { sessionID : 'ses_unknown' } )
// then
await expect ( errorHandler ) . resolves . toBeUndefined ( )
expect ( mockExecuteAction ) . not . toHaveBeenCalled ( )
} )
test ( '#given killTmuxSessionIfExists throws #when cleanup runs #then cleanup still completes without throwing' , async ( ) = > {
// given
mockKillTmuxSessionIfExists . mockClear ( )
mockKillTmuxSessionIfExists . mockImplementationOnce ( async ( ) = > {
throw new Error ( 'simulated teardown failure' )
} )
const { TmuxSessionManager } = await import ( './manager' )
const manager = new TmuxSessionManager ( createMockContext ( ) , createTmuxConfig ( {
enabled : true ,
isolation : 'session' ,
} ) , mockTmuxDeps )
// when
const cleanupPromise = manager . cleanup ( )
// then
await expect ( cleanupPromise ) . resolves . toBeUndefined ( )
expect ( mockKillTmuxSessionIfExists ) . toHaveBeenCalledTimes ( 1 )
} )
2026-01-26 12:02:37 +09:00
} )
2026-02-01 11:11:35 +01:00
2026-01-26 12:02:37 +09:00
} )
describe ( 'DecisionEngine' , ( ) = > {
describe ( 'calculateCapacity' , ( ) = > {
2026-01-26 15:11:16 +09:00
test ( 'calculates correct 2D grid capacity' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
const { calculateCapacity } = await import ( './decision-engine' )
2026-02-01 16:47:50 +09:00
// when
2026-01-26 15:11:16 +09:00
const result = calculateCapacity ( 212 , 44 )
2026-01-26 12:02:37 +09:00
2026-02-01 16:47:50 +09:00
// then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
2026-01-26 15:11:16 +09:00
expect ( result . cols ) . toBe ( 2 )
expect ( result . rows ) . toBe ( 3 )
expect ( result . total ) . toBe ( 6 )
2026-01-26 12:02:37 +09:00
} )
2026-01-26 15:11:16 +09:00
test ( 'returns 0 cols when agent area too narrow' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
const { calculateCapacity } = await import ( './decision-engine' )
2026-02-01 16:47:50 +09:00
// when
2026-01-26 15:11:16 +09:00
const result = calculateCapacity ( 100 , 44 )
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then - availableWidth=50, cols=50/53=0
2026-01-26 15:11:16 +09:00
expect ( result . cols ) . toBe ( 0 )
expect ( result . total ) . toBe ( 0 )
2026-01-26 12:02:37 +09:00
} )
} )
2026-01-25 15:34:10 +09:00
2026-01-26 12:02:37 +09:00
describe ( 'decideSpawnActions' , ( ) = > {
2026-01-26 15:11:16 +09:00
test ( 'returns spawn action with splitDirection when under capacity' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
const { decideSpawnActions } = await import ( './decision-engine' )
const state : WindowState = {
2026-01-26 15:11:16 +09:00
windowWidth : 212 ,
windowHeight : 44 ,
2026-01-26 12:02:37 +09:00
mainPane : {
paneId : '%0' ,
2026-01-26 15:11:16 +09:00
width : 106 ,
height : 44 ,
2026-01-26 12:02:37 +09:00
left : 0 ,
2026-01-26 15:11:16 +09:00
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'main' ,
isActive : true ,
} ,
agentPanes : [ ] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
const decision = decideSpawnActions (
state ,
'ses_1' ,
'Test Task' ,
{ mainPaneMinWidth : 120 , agentPaneWidth : 40 } ,
[ ]
)
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( decision . canSpawn ) . toBe ( true )
expect ( decision . actions ) . toHaveLength ( 1 )
2026-01-26 15:11:16 +09:00
expect ( decision . actions [ 0 ] . type ) . toBe ( 'spawn' )
if ( decision . actions [ 0 ] . type === 'spawn' ) {
expect ( decision . actions [ 0 ] . sessionId ) . toBe ( 'ses_1' )
expect ( decision . actions [ 0 ] . description ) . toBe ( 'Test Task' )
expect ( decision . actions [ 0 ] . targetPaneId ) . toBe ( '%0' )
expect ( decision . actions [ 0 ] . splitDirection ) . toBe ( '-h' )
}
2026-01-26 12:02:37 +09:00
} )
2026-02-15 03:26:39 +08:00
test ( 'returns canSpawn=false when split not possible' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - small window where split is never possible
2026-01-26 12:02:37 +09:00
const { decideSpawnActions } = await import ( './decision-engine' )
const state : WindowState = {
windowWidth : 160 ,
2026-01-26 15:11:16 +09:00
windowHeight : 11 ,
2026-01-26 12:02:37 +09:00
mainPane : {
paneId : '%0' ,
2026-01-26 15:11:16 +09:00
width : 80 ,
height : 11 ,
2026-01-26 12:02:37 +09:00
left : 0 ,
2026-01-26 15:11:16 +09:00
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'main' ,
isActive : true ,
} ,
agentPanes : [
{
paneId : '%1' ,
2026-01-26 15:11:16 +09:00
width : 80 ,
height : 11 ,
left : 80 ,
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'omo-subagent-Old' ,
isActive : false ,
} ,
] ,
}
const sessionMappings = [
{ sessionId : 'ses_old' , paneId : '%1' , createdAt : new Date ( '2024-01-01' ) } ,
]
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
const decision = decideSpawnActions (
state ,
'ses_new' ,
'New Task' ,
{ mainPaneMinWidth : 120 , agentPaneWidth : 40 } ,
sessionMappings
)
2026-02-15 03:26:39 +08:00
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so attach is deferred
expect ( decision . canSpawn ) . toBe ( false )
expect ( decision . actions ) . toHaveLength ( 0 )
expect ( decision . reason ) . toContain ( 'defer' )
2026-01-26 12:02:37 +09:00
} )
2026-01-26 15:11:16 +09:00
test ( 'returns canSpawn=false when window too small' , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-26 12:02:37 +09:00
const { decideSpawnActions } = await import ( './decision-engine' )
const state : WindowState = {
2026-01-26 15:11:16 +09:00
windowWidth : 60 ,
windowHeight : 5 ,
2026-01-26 12:02:37 +09:00
mainPane : {
paneId : '%0' ,
2026-01-26 15:11:16 +09:00
width : 30 ,
height : 5 ,
2026-01-26 12:02:37 +09:00
left : 0 ,
2026-01-26 15:11:16 +09:00
top : 0 ,
2026-01-26 12:02:37 +09:00
title : 'main' ,
isActive : true ,
} ,
agentPanes : [ ] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-26 12:02:37 +09:00
const decision = decideSpawnActions (
state ,
'ses_1' ,
'Test Task' ,
{ mainPaneMinWidth : 120 , agentPaneWidth : 40 } ,
[ ]
)
2026-01-25 15:34:10 +09:00
2026-02-01 16:47:50 +09:00
// then
2026-01-26 12:02:37 +09:00
expect ( decision . canSpawn ) . toBe ( false )
2026-01-26 15:11:16 +09:00
expect ( decision . reason ) . toContain ( 'too small' )
2026-01-25 15:34:10 +09:00
} )
} )
} )