session-manager.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. import { beforeEach, describe, expect, mock, test } from 'bun:test';
  2. import { MultiplexerSessionManager } from './session-manager';
  3. // Define the mock multiplexer
  4. const mockMultiplexer = {
  5. type: 'tmux' as const,
  6. isAvailable: mock(async () => true),
  7. isInsideSession: mock(() => true),
  8. spawnPane: mock(async () => ({
  9. success: true,
  10. paneId: '%mock-pane',
  11. })),
  12. closePane: mock(async () => true),
  13. applyLayout: mock(async () => {}),
  14. };
  15. // Mock the multiplexer module
  16. mock.module('../multiplexer', () => ({
  17. getMultiplexer: () => mockMultiplexer,
  18. isServerRunning: mock(async () => true),
  19. startAvailabilityCheck: () => {},
  20. }));
  21. // Mock the plugin context
  22. function createMockContext(overrides?: {
  23. sessionStatusResult?: { data?: Record<string, { type: string }> };
  24. directory?: string;
  25. }) {
  26. const defaultPort = process.env.OPENCODE_PORT ?? '4096';
  27. return {
  28. client: {
  29. session: {
  30. status: mock(
  31. async () => overrides?.sessionStatusResult ?? { data: {} },
  32. ),
  33. },
  34. },
  35. directory: overrides?.directory ?? '/test/directory',
  36. serverUrl: new URL(`http://localhost:${defaultPort}`),
  37. } as any;
  38. }
  39. const defaultMultiplexerConfig = {
  40. type: 'tmux' as const,
  41. layout: 'main-vertical' as const,
  42. main_pane_size: 60,
  43. };
  44. function createDeferred<T>() {
  45. let resolve!: (value: T) => void;
  46. let reject!: (reason?: unknown) => void;
  47. const promise = new Promise<T>((res, rej) => {
  48. resolve = res;
  49. reject = rej;
  50. });
  51. return { promise, resolve, reject };
  52. }
  53. describe('MultiplexerSessionManager', () => {
  54. beforeEach(() => {
  55. mockMultiplexer.spawnPane.mockReset();
  56. mockMultiplexer.spawnPane.mockResolvedValue({
  57. success: true,
  58. paneId: '%mock-pane',
  59. });
  60. mockMultiplexer.closePane.mockReset();
  61. mockMultiplexer.closePane.mockResolvedValue(true);
  62. mockMultiplexer.isInsideSession.mockReset();
  63. mockMultiplexer.isInsideSession.mockReturnValue(true);
  64. });
  65. describe('constructor', () => {
  66. test('initializes with config', () => {
  67. const ctx = createMockContext();
  68. const manager = new MultiplexerSessionManager(
  69. ctx,
  70. defaultMultiplexerConfig,
  71. );
  72. expect(manager).toBeDefined();
  73. });
  74. });
  75. describe('onSessionCreated', () => {
  76. test('spawns pane for child sessions', async () => {
  77. const ctx = createMockContext();
  78. const manager = new MultiplexerSessionManager(
  79. ctx,
  80. defaultMultiplexerConfig,
  81. );
  82. await manager.onSessionCreated({
  83. type: 'session.created',
  84. properties: {
  85. info: {
  86. id: 'child-123',
  87. parentID: 'parent-456',
  88. title: 'Test Worker',
  89. },
  90. },
  91. });
  92. expect(mockMultiplexer.spawnPane).toHaveBeenCalled();
  93. expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
  94. 'child-123',
  95. 'Test Worker',
  96. `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
  97. '/test/directory',
  98. );
  99. });
  100. test('ignores sessions without parentID', async () => {
  101. const ctx = createMockContext();
  102. const manager = new MultiplexerSessionManager(
  103. ctx,
  104. defaultMultiplexerConfig,
  105. );
  106. await manager.onSessionCreated({
  107. type: 'session.created',
  108. properties: {
  109. info: {
  110. id: 'root-session',
  111. title: 'Main Chat',
  112. },
  113. },
  114. });
  115. expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
  116. });
  117. test('prefers child session directory when present', async () => {
  118. const ctx = createMockContext({ directory: '/parent/directory' });
  119. const manager = new MultiplexerSessionManager(
  120. ctx,
  121. defaultMultiplexerConfig,
  122. );
  123. await manager.onSessionCreated({
  124. type: 'session.created',
  125. properties: {
  126. info: {
  127. id: 'child-456',
  128. parentID: 'parent-456',
  129. title: 'Nested Worker',
  130. directory: '/child/directory',
  131. },
  132. },
  133. });
  134. expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
  135. 'child-456',
  136. 'Nested Worker',
  137. `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
  138. '/child/directory',
  139. );
  140. });
  141. test('ignores if disabled in config', async () => {
  142. const ctx = createMockContext();
  143. const manager = new MultiplexerSessionManager(ctx, {
  144. ...defaultMultiplexerConfig,
  145. type: 'none',
  146. });
  147. await manager.onSessionCreated({
  148. type: 'session.created',
  149. properties: {
  150. info: { id: 'child', parentID: 'parent' },
  151. },
  152. });
  153. expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
  154. });
  155. test('does not spawn twice for duplicate create events while spawning', async () => {
  156. const ctx = createMockContext();
  157. const manager = new MultiplexerSessionManager(
  158. ctx,
  159. defaultMultiplexerConfig,
  160. );
  161. const deferred = createDeferred<{ success: true; paneId: string }>();
  162. mockMultiplexer.spawnPane.mockImplementationOnce(() => deferred.promise);
  163. const event = {
  164. type: 'session.created',
  165. properties: {
  166. info: {
  167. id: 'child-race',
  168. parentID: 'parent-race',
  169. title: 'Race Worker',
  170. },
  171. },
  172. };
  173. const firstCreate = manager.onSessionCreated(event);
  174. const secondCreate = manager.onSessionCreated(event);
  175. await Promise.resolve();
  176. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
  177. deferred.resolve({ success: true, paneId: 'p-race' });
  178. await Promise.all([firstCreate, secondCreate]);
  179. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
  180. });
  181. });
  182. describe('polling and closure', () => {
  183. test('closes pane when session becomes idle', async () => {
  184. const ctx = createMockContext();
  185. mockMultiplexer.spawnPane.mockResolvedValue({
  186. success: true,
  187. paneId: 'p-1',
  188. });
  189. const manager = new MultiplexerSessionManager(
  190. ctx,
  191. defaultMultiplexerConfig,
  192. );
  193. // Register session
  194. await manager.onSessionCreated({
  195. type: 'session.created',
  196. properties: { info: { id: 'c1', parentID: 'p1' } },
  197. });
  198. // Mock status
  199. ctx.client.session.status.mockResolvedValue({
  200. data: { c1: { type: 'idle' } },
  201. });
  202. await (manager as any).pollSessions();
  203. expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
  204. });
  205. test('does not close on transient status absence', async () => {
  206. const ctx = createMockContext();
  207. const manager = new MultiplexerSessionManager(
  208. ctx,
  209. defaultMultiplexerConfig,
  210. );
  211. await manager.onSessionCreated({
  212. type: 'session.created',
  213. properties: { info: { id: 'c1', parentID: 'p1' } },
  214. });
  215. ctx.client.session.status.mockResolvedValue({ data: {} });
  216. await (manager as any).pollSessions();
  217. expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
  218. });
  219. test('respawns pane on busy for known prior session', async () => {
  220. const ctx = createMockContext();
  221. const manager = new MultiplexerSessionManager(
  222. ctx,
  223. defaultMultiplexerConfig,
  224. );
  225. mockMultiplexer.spawnPane
  226. .mockResolvedValueOnce({
  227. success: true,
  228. paneId: 'p-1',
  229. })
  230. .mockResolvedValueOnce({
  231. success: true,
  232. paneId: 'p-2',
  233. });
  234. await manager.onSessionCreated({
  235. type: 'session.created',
  236. properties: {
  237. info: {
  238. id: 'child-789',
  239. parentID: 'parent-789',
  240. title: 'Worker',
  241. directory: '/task/dir',
  242. },
  243. },
  244. });
  245. ctx.client.session.status.mockResolvedValue({
  246. data: { 'child-789': { type: 'idle' } },
  247. });
  248. await (manager as any).pollSessions();
  249. await manager.onSessionStatus({
  250. type: 'session.status',
  251. properties: {
  252. sessionID: 'child-789',
  253. status: { type: 'busy' },
  254. },
  255. });
  256. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(2);
  257. expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
  258. 'child-789',
  259. 'Worker',
  260. `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
  261. '/task/dir',
  262. );
  263. expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
  264. expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
  265. });
  266. test('does nothing on busy for unknown session', async () => {
  267. const ctx = createMockContext();
  268. const manager = new MultiplexerSessionManager(
  269. ctx,
  270. defaultMultiplexerConfig,
  271. );
  272. await manager.onSessionStatus({
  273. type: 'session.status',
  274. properties: {
  275. sessionID: 'unknown-session',
  276. status: { type: 'busy' },
  277. },
  278. });
  279. expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
  280. });
  281. test('re-checks tracked sessions after async respawn guard', async () => {
  282. const ctx = createMockContext();
  283. const manager = new MultiplexerSessionManager(
  284. ctx,
  285. defaultMultiplexerConfig,
  286. );
  287. mockMultiplexer.spawnPane
  288. .mockResolvedValueOnce({ success: true, paneId: 'p-1' })
  289. .mockResolvedValueOnce({
  290. success: true,
  291. paneId: 'p-should-not-happen',
  292. });
  293. await manager.onSessionCreated({
  294. type: 'session.created',
  295. properties: {
  296. info: {
  297. id: 'child-999',
  298. parentID: 'parent-999',
  299. title: 'Worker',
  300. directory: '/task/dir',
  301. },
  302. },
  303. });
  304. ctx.client.session.status.mockResolvedValue({
  305. data: { 'child-999': { type: 'idle' } },
  306. });
  307. await (manager as any).pollSessions();
  308. const respawnPromise = (manager as any).respawnIfKnown('child-999');
  309. (manager as any).sessions.set('child-999', {
  310. sessionId: 'child-999',
  311. paneId: 'p-existing',
  312. parentId: 'parent-999',
  313. title: 'Worker',
  314. directory: '/task/dir',
  315. createdAt: Date.now(),
  316. lastSeenAt: Date.now(),
  317. });
  318. await respawnPromise;
  319. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
  320. expect((manager as any).sessions.get('child-999')?.paneId).toBe(
  321. 'p-existing',
  322. );
  323. });
  324. test('does not respawn while initial pane spawn is still in progress', async () => {
  325. const ctx = createMockContext();
  326. const manager = new MultiplexerSessionManager(
  327. ctx,
  328. defaultMultiplexerConfig,
  329. );
  330. const deferred = createDeferred<{ success: true; paneId: string }>();
  331. mockMultiplexer.spawnPane.mockImplementationOnce(() => deferred.promise);
  332. const createPromise = manager.onSessionCreated({
  333. type: 'session.created',
  334. properties: {
  335. info: {
  336. id: 'child-busy-race',
  337. parentID: 'parent-busy-race',
  338. title: 'Busy Worker',
  339. directory: '/task/dir',
  340. },
  341. },
  342. });
  343. await manager.onSessionStatus({
  344. type: 'session.status',
  345. properties: {
  346. sessionID: 'child-busy-race',
  347. status: { type: 'busy' },
  348. },
  349. });
  350. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
  351. deferred.resolve({ success: true, paneId: 'p-busy-race' });
  352. await createPromise;
  353. expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
  354. });
  355. });
  356. describe('cleanup', () => {
  357. test('closes all tracked panes concurrently', async () => {
  358. const ctx = createMockContext();
  359. mockMultiplexer.spawnPane
  360. .mockResolvedValueOnce({ success: true, paneId: 'p1' })
  361. .mockResolvedValueOnce({ success: true, paneId: 'p2' });
  362. const manager = new MultiplexerSessionManager(
  363. ctx,
  364. defaultMultiplexerConfig,
  365. );
  366. await manager.onSessionCreated({
  367. type: 'session.created',
  368. properties: { info: { id: 's1', parentID: 'p1' } },
  369. });
  370. await manager.onSessionCreated({
  371. type: 'session.created',
  372. properties: { info: { id: 's2', parentID: 'p2' } },
  373. });
  374. await manager.cleanup();
  375. expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(2);
  376. expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p1');
  377. expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p2');
  378. });
  379. });
  380. });
  381. // Backward compatibility test
  382. describe('TmuxSessionManager (backward compatibility)', () => {
  383. test('TmuxSessionManager is alias for MultiplexerSessionManager', async () => {
  384. const { TmuxSessionManager } = await import('./session-manager');
  385. expect(TmuxSessionManager).toBe(MultiplexerSessionManager);
  386. });
  387. });