manager.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. import { describe, expect, mock, test } from 'bun:test';
  2. import * as fs from 'node:fs/promises';
  3. import { createServer } from 'node:http';
  4. import type { PluginConfig } from '../config';
  5. import { readDashboardAuthFile } from './dashboard';
  6. import { createInterviewManager } from './manager';
  7. // Helper to find a free port (matches interview.test.ts pattern)
  8. async function findFreePort(): Promise<number> {
  9. return new Promise((resolve, reject) => {
  10. const server = createServer();
  11. server.listen(0, () => {
  12. const address = server.address();
  13. if (address && typeof address !== 'string') {
  14. const port = address.port;
  15. server.close(() => resolve(port));
  16. } else {
  17. server.close(() => reject(new Error('Failed to get port')));
  18. }
  19. });
  20. });
  21. }
  22. // Mock context pattern from interview.test.ts
  23. function createMockContext(overrides?: {
  24. directory?: string;
  25. messagesData?: Array<{
  26. info?: { role: string };
  27. parts?: Array<{ type: string; text?: string }>;
  28. }>;
  29. promptImpl?: (args: any) => Promise<unknown>;
  30. }) {
  31. const messagesData = overrides?.messagesData ?? [];
  32. return {
  33. client: {
  34. session: {
  35. messages: mock(async () => ({ data: messagesData })),
  36. prompt: mock(async (args: any) => {
  37. if (overrides?.promptImpl) {
  38. return await overrides.promptImpl(args);
  39. }
  40. return {};
  41. }),
  42. promptAsync: mock(async (args: any) => {
  43. if (overrides?.promptImpl) {
  44. return await overrides.promptImpl(args);
  45. }
  46. return {};
  47. }),
  48. },
  49. },
  50. directory: overrides?.directory ?? '/test/directory',
  51. } as any;
  52. }
  53. function createTestConfig(
  54. overrides: Partial<NonNullable<PluginConfig['interview']>> = {},
  55. ): PluginConfig {
  56. return {
  57. interview: {
  58. autoOpenBrowser: false,
  59. ...overrides,
  60. },
  61. } as PluginConfig;
  62. }
  63. // Helper to extract text from output parts
  64. function _extractOutputText(output: {
  65. parts: Array<{ type: string; text?: string }>;
  66. }): string {
  67. const textPart = output.parts.find((part) => part.type === 'text');
  68. return textPart?.text ?? '';
  69. }
  70. describe('interview manager - per-session mode', () => {
  71. describe('basic functionality', () => {
  72. test('returns correct interface when port is 0 (default)', () => {
  73. const ctx = createMockContext();
  74. const config = createTestConfig({ port: 0 });
  75. const manager = createInterviewManager(ctx, config);
  76. expect(manager).toHaveProperty('registerCommand');
  77. expect(manager).toHaveProperty('handleCommandExecuteBefore');
  78. expect(manager).toHaveProperty('handleEvent');
  79. expect(typeof manager.registerCommand).toBe('function');
  80. expect(typeof manager.handleCommandExecuteBefore).toBe('function');
  81. expect(typeof manager.handleEvent).toBe('function');
  82. });
  83. test('creates interview with /interview command', async () => {
  84. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  85. const ctx = createMockContext({ directory: tempDir });
  86. const config = createTestConfig({ port: 0 });
  87. const manager = createInterviewManager(ctx, config);
  88. const output = { parts: [] as Array<{ type: string; text?: string }> };
  89. await manager.handleCommandExecuteBefore(
  90. {
  91. command: 'interview',
  92. sessionID: 'session-123',
  93. arguments: 'My App Idea',
  94. },
  95. output,
  96. );
  97. // Should inject kickoff prompt into output
  98. expect(output.parts.length).toBe(1);
  99. expect(output.parts[0].type).toBe('text');
  100. expect(output.parts[0].text).toContain('My App Idea');
  101. expect(output.parts[0].text).toContain('<interview_state>');
  102. // Cleanup
  103. await fs.rm(tempDir, { recursive: true, force: true });
  104. });
  105. test('marks interview as abandoned on session.deleted event', async () => {
  106. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  107. const ctx = createMockContext({ directory: tempDir });
  108. const config = createTestConfig({ port: 0 });
  109. const manager = createInterviewManager(ctx, config);
  110. // Create interview
  111. const output = { parts: [] as Array<{ type: string; text?: string }> };
  112. await manager.handleCommandExecuteBefore(
  113. {
  114. command: 'interview',
  115. sessionID: 'session-delete-test',
  116. arguments: 'Delete Test',
  117. },
  118. output,
  119. );
  120. // Simulate session deletion
  121. await manager.handleEvent({
  122. event: {
  123. type: 'session.deleted',
  124. properties: { sessionID: 'session-delete-test' },
  125. },
  126. });
  127. // Interview should still exist (file not deleted)
  128. const interviewDir = `${tempDir}/interview`;
  129. const remainingFiles = await fs.readdir(interviewDir);
  130. expect(remainingFiles.length).toBe(1);
  131. // Status is only tracked in memory, not written to markdown
  132. // We verify the session deletion handler doesn't throw
  133. // Cleanup
  134. await fs.rm(tempDir, { recursive: true, force: true });
  135. });
  136. test('registers session when interview is created', async () => {
  137. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  138. const ctx = createMockContext({ directory: tempDir });
  139. const freePort = await findFreePort();
  140. const config = createTestConfig({
  141. port: freePort,
  142. dashboard: true,
  143. });
  144. const manager = createInterviewManager(ctx, config);
  145. // Wait for dashboard init
  146. await new Promise((r) => setTimeout(r, 100));
  147. try {
  148. // Create interview (should trigger session registration)
  149. const output = { parts: [] as Array<{ type: string; text?: string }> };
  150. await manager.handleCommandExecuteBefore(
  151. {
  152. command: 'interview',
  153. sessionID: 'session-reg-after-cmd',
  154. arguments: 'Register After Cmd',
  155. },
  156. output,
  157. );
  158. // Extract interview ID
  159. const promptCalls = ctx.client.session.prompt.mock.calls;
  160. expect(promptCalls.length).toBeGreaterThan(0);
  161. const text =
  162. promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
  163. const match = text.match(/interview\/([^\s]+)/);
  164. expect(match).not.toBeNull();
  165. const interviewId = match?.[1];
  166. // Give registration a moment
  167. await new Promise((r) => setTimeout(r, 100));
  168. // Read auth token
  169. const auth = await readDashboardAuthFile(freePort);
  170. expect(auth).not.toBeNull();
  171. // Verify session is registered (interview exists in cache)
  172. const listResponse = await fetch(
  173. `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
  174. );
  175. expect(listResponse.status).toBe(200);
  176. } finally {
  177. await fs.rm(tempDir, { recursive: true, force: true });
  178. }
  179. });
  180. });
  181. describe('dashboard: true with port 0', () => {
  182. test('activates dashboard mode and creates interview', async () => {
  183. const freePort = await findFreePort();
  184. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  185. const ctx = createMockContext({ directory: tempDir });
  186. const config = createTestConfig({
  187. port: freePort,
  188. dashboard: true,
  189. });
  190. const manager = createInterviewManager(ctx, config);
  191. // Wait for async init
  192. await new Promise((r) => setTimeout(r, 100));
  193. try {
  194. const output = { parts: [] as Array<{ type: string; text?: string }> };
  195. await manager.handleCommandExecuteBefore(
  196. {
  197. command: 'interview',
  198. sessionID: 'session-dashboard-bool',
  199. arguments: 'Dashboard Bool Test',
  200. },
  201. output,
  202. );
  203. expect(output.parts.length).toBe(1);
  204. expect(output.parts[0].text).toContain('Dashboard Bool Test');
  205. } finally {
  206. await fs.rm(tempDir, { recursive: true, force: true });
  207. }
  208. });
  209. });
  210. });
  211. describe('interview manager - state push callback wiring', () => {
  212. test('in dashboard mode, state push callback is wired', async () => {
  213. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  214. const ctx = createMockContext({ directory: tempDir });
  215. const freePort = await findFreePort();
  216. const config = createTestConfig({
  217. port: freePort,
  218. dashboard: true,
  219. });
  220. const manager = createInterviewManager(ctx, config);
  221. // Wait for dashboard init
  222. await new Promise((r) => setTimeout(r, 100));
  223. try {
  224. // Create interview
  225. const output = { parts: [] as Array<{ type: string; text?: string }> };
  226. await manager.handleCommandExecuteBefore(
  227. {
  228. command: 'interview',
  229. sessionID: 'session-state-callback',
  230. arguments: 'State Callback Test',
  231. },
  232. output,
  233. );
  234. // Extract interview ID from prompt calls
  235. const promptCalls = ctx.client.session.prompt.mock.calls;
  236. expect(promptCalls.length).toBeGreaterThan(0);
  237. const text =
  238. promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
  239. const match = text.match(/interview\/([^\s]+)/);
  240. expect(match).not.toBeNull();
  241. const interviewId = match?.[1];
  242. // Give state push a moment
  243. await new Promise((r) => setTimeout(r, 100));
  244. // Read auth token
  245. const auth = await readDashboardAuthFile(freePort);
  246. expect(auth).not.toBeNull();
  247. // Verify state was pushed to dashboard cache
  248. const stateResponse = await fetch(
  249. `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
  250. );
  251. expect(stateResponse.status).toBe(200);
  252. const stateData = (await stateResponse.json()) as {
  253. interview: { idea: string };
  254. mode: string;
  255. };
  256. expect(stateData.interview.idea).toBe('State Callback Test');
  257. } finally {
  258. await fs.rm(tempDir, { recursive: true, force: true });
  259. }
  260. });
  261. test('in per-session mode, setBaseUrlResolver is called', async () => {
  262. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  263. const ctx = createMockContext({ directory: tempDir });
  264. const config = createTestConfig({ port: 0 });
  265. const manager = createInterviewManager(ctx, config);
  266. try {
  267. // Create interview (this triggers server start via setBaseUrlResolver)
  268. const output = { parts: [] as Array<{ type: string; text?: string }> };
  269. await manager.handleCommandExecuteBefore(
  270. {
  271. command: 'interview',
  272. sessionID: 'session-base-url',
  273. arguments: 'Base URL Test',
  274. },
  275. output,
  276. );
  277. // Should create a markdown file (proof that server was initialized)
  278. const interviewDir = `${tempDir}/interview`;
  279. const files = await fs.readdir(interviewDir);
  280. expect(files.length).toBe(1);
  281. } finally {
  282. await fs.rm(tempDir, { recursive: true, force: true });
  283. }
  284. });
  285. });
  286. describe('interview manager - session registration', () => {
  287. test('registers session after handleCommandExecuteBefore in dashboard mode', async () => {
  288. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  289. const ctx = createMockContext({ directory: tempDir });
  290. const freePort = await findFreePort();
  291. const config = createTestConfig({
  292. port: freePort,
  293. dashboard: true,
  294. });
  295. const manager = createInterviewManager(ctx, config);
  296. // Wait for dashboard init
  297. await new Promise((r) => setTimeout(r, 100));
  298. try {
  299. // Create interview (should trigger session registration)
  300. const output = { parts: [] as Array<{ type: string; text?: string }> };
  301. await manager.handleCommandExecuteBefore(
  302. {
  303. command: 'interview',
  304. sessionID: 'session-reg-after-cmd',
  305. arguments: 'Register After Cmd',
  306. },
  307. output,
  308. );
  309. // Extract interview ID
  310. const promptCalls = ctx.client.session.prompt.mock.calls;
  311. expect(promptCalls.length).toBeGreaterThan(0);
  312. const text =
  313. promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
  314. const match = text.match(/interview\/([^\s]+)/);
  315. expect(match).not.toBeNull();
  316. const interviewId = match?.[1];
  317. // Give registration a moment
  318. await new Promise((r) => setTimeout(r, 100));
  319. // Read auth token
  320. const auth = await readDashboardAuthFile(freePort);
  321. expect(auth).not.toBeNull();
  322. // Verify session was registered by checking the interview state
  323. const stateResponse = await fetch(
  324. `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
  325. );
  326. expect(stateResponse.status).toBe(200);
  327. } finally {
  328. await fs.rm(tempDir, { recursive: true, force: true });
  329. }
  330. });
  331. test('removes session on session.deleted event', async () => {
  332. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  333. const ctx = createMockContext({ directory: tempDir });
  334. const freePort = await findFreePort();
  335. const config = createTestConfig({
  336. port: freePort,
  337. dashboard: true,
  338. });
  339. const manager = createInterviewManager(ctx, config);
  340. // Wait for dashboard init
  341. await new Promise((r) => setTimeout(r, 100));
  342. try {
  343. // Create interview
  344. const output = { parts: [] as Array<{ type: string; text?: string }> };
  345. await manager.handleCommandExecuteBefore(
  346. {
  347. command: 'interview',
  348. sessionID: 'session-delete-reg',
  349. arguments: 'Delete Register Test',
  350. },
  351. output,
  352. );
  353. // Extract interview ID
  354. const promptCalls = ctx.client.session.prompt.mock.calls;
  355. expect(promptCalls.length).toBeGreaterThan(0);
  356. const text =
  357. promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
  358. const match = text.match(/interview\/([^\s]+)/);
  359. expect(match).not.toBeNull();
  360. const _interviewId = match?.[1];
  361. // Give registration a moment
  362. await new Promise((r) => setTimeout(r, 100));
  363. // Delete session
  364. await manager.handleEvent({
  365. event: {
  366. type: 'session.deleted',
  367. properties: { sessionID: 'session-delete-reg' },
  368. },
  369. });
  370. // Give cleanup a moment
  371. await new Promise((r) => setTimeout(r, 50));
  372. // Interview file should still exist
  373. const interviewDir = `${tempDir}/interview`;
  374. const files = await fs.readdir(interviewDir);
  375. expect(files.length).toBe(1);
  376. // Status is only tracked in memory, not written to markdown
  377. } finally {
  378. await fs.rm(tempDir, { recursive: true, force: true });
  379. }
  380. });
  381. });
  382. describe('interview manager - edge cases', () => {
  383. test('handles session.status event with idle status', async () => {
  384. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  385. const ctx = createMockContext({ directory: tempDir });
  386. const config = createTestConfig({ port: 0 });
  387. const manager = createInterviewManager(ctx, config);
  388. try {
  389. // Create interview
  390. const output = { parts: [] as Array<{ type: string; text?: string }> };
  391. await manager.handleCommandExecuteBefore(
  392. {
  393. command: 'interview',
  394. sessionID: 'session-idle',
  395. arguments: 'Idle Event Test',
  396. },
  397. output,
  398. );
  399. // Send idle status event
  400. await manager.handleEvent({
  401. event: {
  402. type: 'session.status',
  403. properties: {
  404. sessionID: 'session-idle',
  405. status: { type: 'idle' },
  406. },
  407. },
  408. });
  409. // Should not throw
  410. expect(true).toBe(true);
  411. } finally {
  412. await fs.rm(tempDir, { recursive: true, force: true });
  413. }
  414. });
  415. test('handles session.status event without sessionID in properties', async () => {
  416. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  417. const ctx = createMockContext({ directory: tempDir });
  418. const config = createTestConfig({ port: 0 });
  419. const manager = createInterviewManager(ctx, config);
  420. try {
  421. // Send idle status event without sessionID
  422. await manager.handleEvent({
  423. event: {
  424. type: 'session.status',
  425. properties: {
  426. status: { type: 'idle' },
  427. },
  428. },
  429. });
  430. // Should not throw
  431. expect(true).toBe(true);
  432. } finally {
  433. await fs.rm(tempDir, { recursive: true, force: true });
  434. }
  435. });
  436. test('handles unknown event types', async () => {
  437. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  438. const ctx = createMockContext({ directory: tempDir });
  439. const config = createTestConfig({ port: 0 });
  440. const manager = createInterviewManager(ctx, config);
  441. try {
  442. // Send unknown event type
  443. await manager.handleEvent({
  444. event: {
  445. type: 'unknown.event',
  446. properties: { sessionID: 'session-unknown' },
  447. },
  448. });
  449. // Should not throw
  450. expect(true).toBe(true);
  451. } finally {
  452. await fs.rm(tempDir, { recursive: true, force: true });
  453. }
  454. });
  455. test('handles handleCommandExecuteBefore without sessionID', async () => {
  456. const tempDir = await fs.mkdtemp('/tmp/manager-test-');
  457. const ctx = createMockContext({ directory: tempDir });
  458. const config = createTestConfig({ port: 0 });
  459. const manager = createInterviewManager(ctx, config);
  460. try {
  461. const output = { parts: [] as Array<{ type: string; text?: string }> };
  462. await manager.handleCommandExecuteBefore(
  463. {
  464. command: 'interview',
  465. sessionID: '',
  466. arguments: 'No Session Test',
  467. },
  468. output,
  469. );
  470. // Should create interview (sessionID is optional in per-session mode)
  471. expect(output.parts.length).toBe(1);
  472. } finally {
  473. await fs.rm(tempDir, { recursive: true, force: true });
  474. }
  475. });
  476. });
  477. describe('interview manager - integration with real dashboard', () => {
  478. test('two managers on same port: first becomes dashboard, second becomes session', async () => {
  479. const tempDir1 = await fs.mkdtemp('/tmp/manager-test-');
  480. const tempDir2 = await fs.mkdtemp('/tmp/manager-test-');
  481. const ctx1 = createMockContext({ directory: tempDir1 });
  482. const ctx2 = createMockContext({ directory: tempDir2 });
  483. const freePort = await findFreePort();
  484. const config = createTestConfig({
  485. port: freePort,
  486. dashboard: true,
  487. });
  488. const manager1 = createInterviewManager(ctx1, config);
  489. // Wait for manager1 to become dashboard
  490. await new Promise((r) => setTimeout(r, 100));
  491. try {
  492. // Manager1 should be the dashboard
  493. const healthResponse = await fetch(
  494. `http://127.0.0.1:${freePort}/api/health`,
  495. );
  496. expect(healthResponse.status).toBe(200);
  497. // Manager2 should become a session (not throw when dashboard is found)
  498. const manager2 = createInterviewManager(ctx2, config);
  499. // Wait for manager2 init (probes dashboard)
  500. await new Promise((r) => setTimeout(r, 100));
  501. // Both managers should work
  502. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  503. await manager1.handleCommandExecuteBefore(
  504. {
  505. command: 'interview',
  506. sessionID: 'session-1',
  507. arguments: 'Manager 1 Test',
  508. },
  509. output1,
  510. );
  511. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  512. await manager2.handleCommandExecuteBefore(
  513. {
  514. command: 'interview',
  515. sessionID: 'session-2',
  516. arguments: 'Manager 2 Test',
  517. },
  518. output2,
  519. );
  520. // Give state pushes a moment
  521. await new Promise((r) => setTimeout(r, 100));
  522. // Extract interview IDs
  523. const promptCalls1 = ctx1.client.session.prompt.mock.calls;
  524. const text1 =
  525. promptCalls1[promptCalls1.length - 1][0].body?.parts?.[0]?.text ?? '';
  526. const match1 = text1.match(/interview\/([^\s]+)/);
  527. expect(match1).not.toBeNull();
  528. const interviewId1 = match1?.[1];
  529. const promptCalls2 = ctx2.client.session.prompt.mock.calls;
  530. const text2 =
  531. promptCalls2[promptCalls2.length - 1][0].body?.parts?.[0]?.text ?? '';
  532. const match2 = text2.match(/interview\/([^\s]+)/);
  533. expect(match2).not.toBeNull();
  534. const interviewId2 = match2?.[1];
  535. // Read auth token
  536. const auth = await readDashboardAuthFile(freePort);
  537. expect(auth).not.toBeNull();
  538. // Both interviews should be in dashboard cache
  539. const state1Response = await fetch(
  540. `http://127.0.0.1:${freePort}/api/interviews/${interviewId1}/state?token=${auth?.token}`,
  541. );
  542. expect(state1Response.status).toBe(200);
  543. const state2Response = await fetch(
  544. `http://127.0.0.1:${freePort}/api/interviews/${interviewId2}/state?token=${auth?.token}`,
  545. );
  546. expect(state2Response.status).toBe(200);
  547. } finally {
  548. await fs.rm(tempDir1, { recursive: true, force: true });
  549. await fs.rm(tempDir2, { recursive: true, force: true });
  550. }
  551. });
  552. });