interview.test.ts 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038
  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 * as path from 'node:path';
  5. import { InterviewConfigSchema } from '../config/schema';
  6. import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils';
  7. import { createInterviewServer } from './server';
  8. import {
  9. createInterviewService as createRealInterviewService,
  10. MAX_RETAINED_ABANDONED,
  11. } from './service';
  12. import type { InterviewAnswer } from './types';
  13. import { renderInterviewPage } from './ui';
  14. // Mock the plugin context with mutable message array
  15. function createMockContext(overrides?: {
  16. directory?: string;
  17. messagesData?: Array<{
  18. info?: { role: string };
  19. parts?: Array<{ type: string; text?: string }>;
  20. }>;
  21. promptImpl?: (args: any) => Promise<unknown>;
  22. }) {
  23. // Use a mutable array that can be updated after creation
  24. const messagesData = overrides?.messagesData ?? [];
  25. return {
  26. client: {
  27. session: {
  28. messages: mock(async () => ({ data: messagesData })),
  29. prompt: mock(async (args: any) => {
  30. if (overrides?.promptImpl) {
  31. return await overrides.promptImpl(args);
  32. }
  33. return {};
  34. }),
  35. promptAsync: mock(async (args: any) => {
  36. if (overrides?.promptImpl) {
  37. return await overrides.promptImpl(args);
  38. }
  39. return {};
  40. }),
  41. },
  42. },
  43. directory: overrides?.directory ?? '/test/directory',
  44. } as any;
  45. }
  46. // Helper to extract text from prompt calls
  47. function getPromptTexts(promptMock: {
  48. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  49. }): string[] {
  50. return promptMock.mock.calls
  51. .map((call) => call[0].body?.parts?.[0]?.text ?? '')
  52. .filter(Boolean);
  53. }
  54. // Helper to extract interview ID from the last prompt call
  55. function extractInterviewIdFromLastPrompt(promptMock: {
  56. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  57. }): string | null {
  58. const calls = promptMock.mock.calls;
  59. if (calls.length === 0) return null;
  60. // Get the last call
  61. const lastCall = calls[calls.length - 1];
  62. const text = lastCall[0].body?.parts?.[0]?.text ?? '';
  63. const match = text.match(/interview\/([^\s]+)/);
  64. return match ? match[1] : null;
  65. }
  66. // Helper to extract text from output parts (kickoff/resume prompts go here)
  67. function extractOutputText(output: {
  68. parts: Array<{ type: string; text?: string }>;
  69. }): string {
  70. const textPart = output.parts.find((part) => part.type === 'text');
  71. return textPart?.text ?? '';
  72. }
  73. function requireInterviewId(value: string | null): string {
  74. expect(value).not.toBeNull();
  75. return value as string;
  76. }
  77. function createInterviewService(
  78. ctx: ReturnType<typeof createMockContext>,
  79. config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
  80. deps?: Parameters<typeof createRealInterviewService>[2],
  81. ) {
  82. const resolvedConfig = config
  83. ? InterviewConfigSchema.parse(config)
  84. : undefined;
  85. return createRealInterviewService(ctx, resolvedConfig, {
  86. openBrowser: mock((_url: string) => {}),
  87. ...deps,
  88. });
  89. }
  90. function createTestService(
  91. ctx: ReturnType<typeof createMockContext>,
  92. config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
  93. deps?: Parameters<typeof createRealInterviewService>[2],
  94. ) {
  95. const openBrowserMock = mock((_url: string) => {});
  96. const resolvedConfig = config
  97. ? InterviewConfigSchema.parse(config)
  98. : undefined;
  99. const service = createRealInterviewService(ctx, resolvedConfig, {
  100. openBrowser: openBrowserMock,
  101. ...deps,
  102. });
  103. return {
  104. service,
  105. openBrowserMock,
  106. };
  107. }
  108. function createRuntimeEnv(
  109. overrides: NodeJS.ProcessEnv = {},
  110. ): NodeJS.ProcessEnv {
  111. return { ...overrides };
  112. }
  113. describe('interview service', () => {
  114. describe('/interview <idea> command', () => {
  115. test('creates interview and sends kickoff prompt with UI notification', async () => {
  116. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  117. const ctx = createMockContext({ directory: tempDir });
  118. const service = createInterviewService(ctx);
  119. // Set up base URL resolver to avoid server error
  120. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  121. const output = {
  122. parts: [] as Array<{
  123. type: string;
  124. text?: string;
  125. synthetic?: boolean;
  126. metadata?: Record<string, unknown>;
  127. }>,
  128. };
  129. await service.handleCommandExecuteBefore(
  130. {
  131. command: 'interview',
  132. sessionID: 'session-123',
  133. arguments: 'My App Idea',
  134. },
  135. output,
  136. );
  137. // Should inject kickoff prompt into output
  138. expect(output.parts.length).toBe(1);
  139. expect(output.parts[0].type).toBe('text');
  140. expect(output.parts[0].text).toContain('My App Idea');
  141. expect(output.parts[0].text).toContain('<interview_state>');
  142. expect(output.parts[0]).toMatchObject({
  143. synthetic: true,
  144. metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
  145. });
  146. // Should send UI notification prompt to session
  147. expect(ctx.client.session.prompt).toHaveBeenCalled();
  148. const promptTexts = getPromptTexts(ctx.client.session.prompt);
  149. expect(
  150. promptTexts.some((text) => text.includes('Interview UI ready')),
  151. ).toBe(true);
  152. expect(promptTexts.some((text) => text.includes('/interview/'))).toBe(
  153. true,
  154. );
  155. // Cleanup
  156. await fs.rm(tempDir, { recursive: true, force: true });
  157. });
  158. test('creates markdown file with slug-only filename (no timestamp prefix)', async () => {
  159. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  160. const ctx = createMockContext({ directory: tempDir });
  161. const service = createInterviewService(ctx);
  162. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  163. const output = { parts: [] as Array<{ type: string; text?: string }> };
  164. await service.handleCommandExecuteBefore(
  165. {
  166. command: 'interview',
  167. sessionID: 'session-456',
  168. arguments: 'Test Idea',
  169. },
  170. output,
  171. );
  172. // Check that interview directory and file were created
  173. const interviewDir = path.join(tempDir, 'interview');
  174. const files = await fs.readdir(interviewDir);
  175. expect(files.length).toBe(1);
  176. // Filename should be slug-only, no timestamp prefix
  177. expect(files[0]).toBe('test-idea.md');
  178. expect(files[0]).not.toMatch(/^\d+-/);
  179. // Check file content structure
  180. const content = await fs.readFile(
  181. path.join(interviewDir, files[0]),
  182. 'utf8',
  183. );
  184. expect(content).toContain('# Test Idea');
  185. expect(content).toContain('## Current spec');
  186. expect(content).toContain('## Q&A history');
  187. // Cleanup
  188. await fs.rm(tempDir, { recursive: true, force: true });
  189. });
  190. });
  191. describe('answer submission', () => {
  192. test('appends only Q/A history to markdown document', async () => {
  193. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  194. // Start with empty messages, then add questions after interview creation
  195. const messagesData: Array<{
  196. info?: { role: string };
  197. parts?: Array<{ type: string; text?: string }>;
  198. }> = [];
  199. const ctx = createMockContext({
  200. directory: tempDir,
  201. messagesData,
  202. });
  203. const service = createInterviewService(ctx);
  204. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  205. // Create interview first (with empty messages, so baseMessageCount = 0)
  206. const output = { parts: [] as Array<{ type: string; text?: string }> };
  207. await service.handleCommandExecuteBefore(
  208. {
  209. command: 'interview',
  210. sessionID: 'session-789',
  211. arguments: 'Platform App',
  212. },
  213. output,
  214. );
  215. // Get the interview ID from the prompt calls
  216. const interviewId = extractInterviewIdFromLastPrompt(
  217. ctx.client.session.prompt,
  218. );
  219. const requiredInterviewId = requireInterviewId(interviewId);
  220. // Now add the questions to messages (simulating agent response)
  221. messagesData.push({
  222. info: { role: 'assistant' },
  223. parts: [
  224. {
  225. type: 'text',
  226. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  227. },
  228. ],
  229. });
  230. // Submit an answer
  231. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'Web' }];
  232. await service.submitAnswers(requiredInterviewId, answers);
  233. // Read the markdown file
  234. const interviewDir = path.join(tempDir, 'interview');
  235. const files = await fs.readdir(interviewDir);
  236. const content = await fs.readFile(
  237. path.join(interviewDir, files[0]),
  238. 'utf8',
  239. );
  240. // Verify Q/A was appended to history section
  241. expect(content).toContain('## Q&A history');
  242. expect(content).toContain('Q: What platform?');
  243. expect(content).toContain('A: Web');
  244. // Verify the Current spec section exists (even if empty after submission)
  245. expect(content).toContain('## Current spec');
  246. // Cleanup
  247. await fs.rm(tempDir, { recursive: true, force: true });
  248. });
  249. test('preserves existing history when appending new answers', async () => {
  250. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  251. // Start with messages that include one answered question and one pending
  252. const messagesData: Array<{
  253. info?: { role: string };
  254. parts?: Array<{ type: string; text?: string }>;
  255. }> = [
  256. // First question and answer
  257. {
  258. info: { role: 'assistant' },
  259. parts: [
  260. {
  261. type: 'text',
  262. text: 'First question.\n<interview_state>\n{\n "summary": "Building an app",\n "questions": [\n {\n "id": "q-1",\n "question": "What is the name?",\n "options": ["App1", "App2"],\n "suggested": "App1"\n }\n ]\n}\n</interview_state>',
  263. },
  264. ],
  265. },
  266. { info: { role: 'user' }, parts: [{ type: 'text', text: 'App1' }] },
  267. // Second question (current)
  268. {
  269. info: { role: 'assistant' },
  270. parts: [
  271. {
  272. type: 'text',
  273. text: 'Second question.\n<interview_state>\n{\n "summary": "Building App1",\n "questions": [\n {\n "id": "q-2",\n "question": "What color?",\n "options": ["Red", "Blue"],\n "suggested": "Blue"\n }\n ]\n}\n</interview_state>',
  274. },
  275. ],
  276. },
  277. ];
  278. const ctx = createMockContext({
  279. directory: tempDir,
  280. messagesData,
  281. });
  282. const service = createInterviewService(ctx);
  283. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  284. // Create interview (baseMessageCount will be 3)
  285. const output = { parts: [] as Array<{ type: string; text?: string }> };
  286. await service.handleCommandExecuteBefore(
  287. {
  288. command: 'interview',
  289. sessionID: 'session-abc',
  290. arguments: 'Multi Round App',
  291. },
  292. output,
  293. );
  294. // Get interview ID from prompt calls
  295. const interviewId = extractInterviewIdFromLastPrompt(
  296. ctx.client.session.prompt,
  297. );
  298. const requiredInterviewId = requireInterviewId(interviewId);
  299. // Add a new message simulating agent response after interview creation
  300. // This ensures baseMessageCount (3) < current messages length
  301. messagesData.push({
  302. info: { role: 'assistant' },
  303. parts: [
  304. {
  305. type: 'text',
  306. text: 'Acknowledged.\n<interview_state>\n{\n "summary": "Building App1",\n "questions": [\n {\n "id": "q-2",\n "question": "What color?",\n "options": ["Red", "Blue"],\n "suggested": "Blue"\n }\n ]\n}\n</interview_state>',
  307. },
  308. ],
  309. });
  310. // Submit second answer (q-2 is the active question now)
  311. await service.submitAnswers(requiredInterviewId, [
  312. { questionId: 'q-2', answer: 'Blue' },
  313. ]);
  314. // Read file after submission
  315. const interviewDir = path.join(tempDir, 'interview');
  316. const files = await fs.readdir(interviewDir);
  317. const content = await fs.readFile(
  318. path.join(interviewDir, files[0]),
  319. 'utf8',
  320. );
  321. // Verify Q/A is in history
  322. expect(content).toContain('Q: What color?');
  323. expect(content).toContain('A: Blue');
  324. // Cleanup
  325. await fs.rm(tempDir, { recursive: true, force: true });
  326. });
  327. test('replaces placeholder history on first answer submission', async () => {
  328. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  329. const messagesData: Array<{
  330. info?: { role: string };
  331. parts?: Array<{ type: string; text?: string }>;
  332. }> = [];
  333. const ctx = createMockContext({
  334. directory: tempDir,
  335. messagesData,
  336. });
  337. const service = createInterviewService(ctx);
  338. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  339. const output = { parts: [] as Array<{ type: string; text?: string }> };
  340. await service.handleCommandExecuteBefore(
  341. {
  342. command: 'interview',
  343. sessionID: 'session-placeholder',
  344. arguments: 'Placeholder Test',
  345. },
  346. output,
  347. );
  348. const interviewId = extractInterviewIdFromLastPrompt(
  349. ctx.client.session.prompt,
  350. );
  351. const requiredInterviewId = requireInterviewId(interviewId);
  352. messagesData.push({
  353. info: { role: 'assistant' },
  354. parts: [
  355. {
  356. type: 'text',
  357. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  358. },
  359. ],
  360. });
  361. await service.submitAnswers(requiredInterviewId, [
  362. { questionId: 'q-1', answer: 'Web' },
  363. ]);
  364. const interviewDir = path.join(tempDir, 'interview');
  365. const files = await fs.readdir(interviewDir);
  366. const content = await fs.readFile(
  367. path.join(interviewDir, files[0]),
  368. 'utf8',
  369. );
  370. expect(content).not.toContain('## Q&A history\n\nNo answers yet.\n\nQ:');
  371. expect(content).toContain('## Q&A history\n\nQ: What platform?\nA: Web');
  372. await fs.rm(tempDir, { recursive: true, force: true });
  373. });
  374. test('rejects concurrent submission when first request holds busy lock', async () => {
  375. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  376. // Start with empty messages
  377. const messagesData: Array<{
  378. info?: { role: string };
  379. parts?: Array<{ type: string; text?: string }>;
  380. }> = [];
  381. // Create a prompt that delays to hold the lock
  382. let promptStarted = false;
  383. const ctx = createMockContext({
  384. directory: tempDir,
  385. messagesData,
  386. promptImpl: async () => {
  387. promptStarted = true;
  388. // Delay to hold the lock during test
  389. await new Promise((resolve) => setTimeout(resolve, 200));
  390. return {};
  391. },
  392. });
  393. const service = createInterviewService(ctx);
  394. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  395. // Create interview first (baseMessageCount = 0)
  396. const output = { parts: [] as Array<{ type: string; text?: string }> };
  397. await service.handleCommandExecuteBefore(
  398. {
  399. command: 'interview',
  400. sessionID: 'session-concurrent',
  401. arguments: 'Concurrent Test',
  402. },
  403. output,
  404. );
  405. const interviewId = extractInterviewIdFromLastPrompt(
  406. ctx.client.session.prompt,
  407. );
  408. const requiredInterviewId = requireInterviewId(interviewId);
  409. // Now add the agent response with questions
  410. messagesData.push({
  411. info: { role: 'assistant' },
  412. parts: [
  413. {
  414. type: 'text',
  415. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  416. },
  417. ],
  418. });
  419. // Start first submission (will hold lock due to slow prompt)
  420. const firstSubmissionPromise = service.submitAnswers(
  421. requiredInterviewId,
  422. [{ questionId: 'q-1', answer: 'Web' }],
  423. );
  424. // Wait for prompt to start (indicates lock is acquired)
  425. while (!promptStarted) {
  426. await new Promise((resolve) => setTimeout(resolve, 10));
  427. }
  428. // Second submission should be rejected immediately (busy lock held)
  429. await expect(
  430. service.submitAnswers(requiredInterviewId, [
  431. { questionId: 'q-1', answer: 'Mobile' },
  432. ]),
  433. ).rejects.toThrow('Interview session is busy');
  434. // Wait for first submission to complete (it will succeed after 200ms delay)
  435. await firstSubmissionPromise;
  436. // Cleanup
  437. await fs.rm(tempDir, { recursive: true, force: true });
  438. });
  439. test('busy lock released when validation fails after lock acquired', async () => {
  440. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  441. // Start with empty messages
  442. const messagesData: Array<{
  443. info?: { role: string };
  444. parts?: Array<{ type: string; text?: string }>;
  445. }> = [];
  446. const ctx = createMockContext({
  447. directory: tempDir,
  448. messagesData,
  449. });
  450. const service = createInterviewService(ctx);
  451. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  452. // Create interview first (baseMessageCount = 0)
  453. const output = { parts: [] as Array<{ type: string; text?: string }> };
  454. await service.handleCommandExecuteBefore(
  455. {
  456. command: 'interview',
  457. sessionID: 'session-retry',
  458. arguments: 'Retry Test',
  459. },
  460. output,
  461. );
  462. const interviewId = extractInterviewIdFromLastPrompt(
  463. ctx.client.session.prompt,
  464. );
  465. const requiredInterviewId = requireInterviewId(interviewId);
  466. // Add agent response with questions
  467. messagesData.push({
  468. info: { role: 'assistant' },
  469. parts: [
  470. {
  471. type: 'text',
  472. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  473. },
  474. ],
  475. });
  476. // First submission with invalid answer (wrong question ID)
  477. await expect(
  478. service.submitAnswers(requiredInterviewId, [
  479. { questionId: 'invalid-id', answer: 'Web' },
  480. ]),
  481. ).rejects.toThrow('Answers do not match the current interview questions');
  482. // Second submission with correct answer should succeed (lock was released)
  483. await expect(
  484. service.submitAnswers(requiredInterviewId, [
  485. { questionId: 'q-1', answer: 'Web' },
  486. ]),
  487. ).resolves.toBeUndefined();
  488. // Cleanup
  489. await fs.rm(tempDir, { recursive: true, force: true });
  490. });
  491. test('busy lock released when no active questions validation fails', async () => {
  492. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  493. // Start with empty messages (no questions)
  494. const messagesData: Array<{
  495. info?: { role: string };
  496. parts?: Array<{ type: string; text?: string }>;
  497. }> = [
  498. {
  499. info: { role: 'assistant' },
  500. parts: [
  501. {
  502. type: 'text',
  503. text: 'Waiting.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  504. },
  505. ],
  506. },
  507. ];
  508. const ctx = createMockContext({
  509. directory: tempDir,
  510. messagesData,
  511. });
  512. const service = createInterviewService(ctx);
  513. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  514. // Create interview (baseMessageCount will be 1)
  515. const output = { parts: [] as Array<{ type: string; text?: string }> };
  516. await service.handleCommandExecuteBefore(
  517. {
  518. command: 'interview',
  519. sessionID: 'session-no-questions',
  520. arguments: 'No Questions Test',
  521. },
  522. output,
  523. );
  524. const interviewId = extractInterviewIdFromLastPrompt(
  525. ctx.client.session.prompt,
  526. );
  527. const requiredInterviewId = requireInterviewId(interviewId);
  528. // Submission with no active questions should fail and release lock
  529. await expect(
  530. service.submitAnswers(requiredInterviewId, [
  531. { questionId: 'q-1', answer: 'Web' },
  532. ]),
  533. ).rejects.toThrow('There are no active interview questions to answer');
  534. // Verify state is not busy after the failed submission
  535. const state = await service.getInterviewState(requiredInterviewId);
  536. expect(state.isBusy).toBe(false);
  537. // Cleanup
  538. await fs.rm(tempDir, { recursive: true, force: true });
  539. });
  540. });
  541. describe('session interview lifecycle', () => {
  542. test('starting /interview with different idea creates fresh interview', async () => {
  543. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  544. const ctx = createMockContext({ directory: tempDir });
  545. const service = createInterviewService(ctx);
  546. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  547. const sessionID = 'session-reuse-test';
  548. // First interview with "Idea One"
  549. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  550. await service.handleCommandExecuteBefore(
  551. { command: 'interview', sessionID, arguments: 'Idea One' },
  552. output1,
  553. );
  554. const interviewId1 = extractInterviewIdFromLastPrompt(
  555. ctx.client.session.prompt,
  556. );
  557. const requiredInterviewId1 = requireInterviewId(interviewId1);
  558. // Second interview with "Idea Two" - should create fresh interview
  559. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  560. await service.handleCommandExecuteBefore(
  561. { command: 'interview', sessionID, arguments: 'Idea Two' },
  562. output2,
  563. );
  564. // Get the second interview ID (should be the last prompt call)
  565. const interviewId2 = extractInterviewIdFromLastPrompt(
  566. ctx.client.session.prompt,
  567. );
  568. const requiredInterviewId2 = requireInterviewId(interviewId2);
  569. // Should be different interview IDs
  570. expect(interviewId1).not.toBe(interviewId2);
  571. // First interview should be marked as abandoned
  572. const state1 = await service.getInterviewState(requiredInterviewId1);
  573. expect(state1.interview.status).toBe('abandoned');
  574. // Second interview should be active
  575. const state2 = await service.getInterviewState(requiredInterviewId2);
  576. expect(state2.interview.idea).toBe('Idea Two');
  577. expect(state2.interview.status).toBe('active');
  578. // Cleanup
  579. await fs.rm(tempDir, { recursive: true, force: true });
  580. });
  581. test('reusing same idea in same session returns existing interview', async () => {
  582. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  583. const ctx = createMockContext({ directory: tempDir });
  584. const service = createInterviewService(ctx);
  585. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  586. const sessionID = 'session-same-idea';
  587. // First call with "Same Idea"
  588. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  589. await service.handleCommandExecuteBefore(
  590. { command: 'interview', sessionID, arguments: 'Same Idea' },
  591. output1,
  592. );
  593. const interviewId1 = extractInterviewIdFromLastPrompt(
  594. ctx.client.session.prompt,
  595. );
  596. expect(interviewId1).not.toBeNull();
  597. // Second call with same idea - should reuse
  598. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  599. await service.handleCommandExecuteBefore(
  600. { command: 'interview', sessionID, arguments: 'Same Idea' },
  601. output2,
  602. );
  603. const interviewId2 = extractInterviewIdFromLastPrompt(
  604. ctx.client.session.prompt,
  605. );
  606. expect(interviewId2).not.toBeNull();
  607. // Should be the same interview ID
  608. expect(interviewId1).toBe(interviewId2);
  609. // Cleanup
  610. await fs.rm(tempDir, { recursive: true, force: true });
  611. });
  612. test('session.deleted event marks interview as abandoned', async () => {
  613. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  614. const ctx = createMockContext({ directory: tempDir });
  615. const service = createInterviewService(ctx);
  616. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  617. const sessionID = 'session-delete-test';
  618. // Create interview
  619. const output = { parts: [] as Array<{ type: string; text?: string }> };
  620. await service.handleCommandExecuteBefore(
  621. { command: 'interview', sessionID, arguments: 'Delete Test' },
  622. output,
  623. );
  624. const interviewId = extractInterviewIdFromLastPrompt(
  625. ctx.client.session.prompt,
  626. );
  627. const requiredInterviewId = requireInterviewId(interviewId);
  628. // Verify interview is active
  629. const stateBefore = await service.getInterviewState(requiredInterviewId);
  630. expect(stateBefore.interview.status).toBe('active');
  631. // Simulate session deletion
  632. await service.handleEvent({
  633. event: {
  634. type: 'session.deleted',
  635. properties: { sessionID },
  636. },
  637. });
  638. // Interview should now be abandoned
  639. const stateAfter = await service.getInterviewState(requiredInterviewId);
  640. expect(stateAfter.interview.status).toBe('abandoned');
  641. // Cleanup
  642. await fs.rm(tempDir, { recursive: true, force: true });
  643. });
  644. });
  645. describe('session status handling', () => {
  646. test('session.status busy marks interview as awaiting-agent', async () => {
  647. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  648. // Start with no questions (awaiting-agent state)
  649. const messagesData: Array<{
  650. info?: { role: string };
  651. parts?: Array<{ type: string; text?: string }>;
  652. }> = [
  653. {
  654. info: { role: 'assistant' },
  655. parts: [
  656. {
  657. type: 'text',
  658. text: 'Waiting for response.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  659. },
  660. ],
  661. },
  662. ];
  663. const ctx = createMockContext({
  664. directory: tempDir,
  665. messagesData,
  666. });
  667. const service = createInterviewService(ctx);
  668. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  669. const sessionID = 'session-busy-test';
  670. // Create interview
  671. const output = { parts: [] as Array<{ type: string; text?: string }> };
  672. await service.handleCommandExecuteBefore(
  673. { command: 'interview', sessionID, arguments: 'Busy Test' },
  674. output,
  675. );
  676. const interviewId = extractInterviewIdFromLastPrompt(
  677. ctx.client.session.prompt,
  678. );
  679. const requiredInterviewId = requireInterviewId(interviewId);
  680. // Initially should be awaiting-agent (no questions)
  681. const stateBefore = await service.getInterviewState(requiredInterviewId);
  682. expect(stateBefore.mode).toBe('awaiting-agent');
  683. // Simulate busy status
  684. await service.handleEvent({
  685. event: {
  686. type: 'session.status',
  687. properties: { sessionID, status: { type: 'busy' } },
  688. },
  689. });
  690. // Should still be awaiting-agent and marked busy
  691. const stateAfter = await service.getInterviewState(requiredInterviewId);
  692. expect(stateAfter.mode).toBe('awaiting-agent');
  693. expect(stateAfter.isBusy).toBe(true);
  694. // Cleanup
  695. await fs.rm(tempDir, { recursive: true, force: true });
  696. });
  697. test('message.updated stores current session model for interview follow-ups', async () => {
  698. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  699. const ctx = createMockContext({ directory: tempDir });
  700. const service = createInterviewService(ctx);
  701. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  702. const sessionID = 'session-model-track';
  703. const output = { parts: [] as Array<{ type: string; text?: string }> };
  704. await service.handleCommandExecuteBefore(
  705. { command: 'interview', sessionID, arguments: 'Model Track Test' },
  706. output,
  707. );
  708. const interviewId = requireInterviewId(
  709. extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
  710. );
  711. await service.handleEvent({
  712. event: {
  713. type: 'message.updated',
  714. properties: {
  715. info: {
  716. sessionID,
  717. providerID: 'openai',
  718. modelID: 'gpt-5.4-mini',
  719. },
  720. },
  721. },
  722. });
  723. ctx.client.session.promptAsync.mock.calls.length = 0;
  724. await service.handleNudgeAction(interviewId, 'more-questions');
  725. const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
  726. expect(call.body.model).toEqual({
  727. providerID: 'openai',
  728. modelID: 'gpt-5.4-mini',
  729. });
  730. await fs.rm(tempDir, { recursive: true, force: true });
  731. });
  732. });
  733. describe('configurable output folder', () => {
  734. test('creates interview in configured output folder', async () => {
  735. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  736. const ctx = createMockContext({ directory: tempDir });
  737. // Create service with custom output folder config
  738. const service = createInterviewService(ctx, {
  739. maxQuestions: 2,
  740. outputFolder: 'custom-interviews',
  741. autoOpenBrowser: true,
  742. });
  743. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  744. const output = { parts: [] as Array<{ type: string; text?: string }> };
  745. await service.handleCommandExecuteBefore(
  746. {
  747. command: 'interview',
  748. sessionID: 'session-custom-folder',
  749. arguments: 'Custom Folder Idea',
  750. },
  751. output,
  752. );
  753. // Check that file was created in custom folder
  754. const customDir = path.join(tempDir, 'custom-interviews');
  755. const files = await fs.readdir(customDir);
  756. expect(files.length).toBe(1);
  757. expect(files[0]).toBe('custom-folder-idea.md');
  758. // Verify the markdownPath in state points to custom folder
  759. const interviewId = extractInterviewIdFromLastPrompt(
  760. ctx.client.session.prompt,
  761. );
  762. const requiredInterviewId = requireInterviewId(interviewId);
  763. const state = await service.getInterviewState(requiredInterviewId);
  764. expect(state.markdownPath).toContain('custom-interviews');
  765. // Cleanup
  766. await fs.rm(tempDir, { recursive: true, force: true });
  767. });
  768. test('handles nested output folder paths', async () => {
  769. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  770. const ctx = createMockContext({ directory: tempDir });
  771. // Create service with nested output folder path
  772. const service = createInterviewService(ctx, {
  773. maxQuestions: 2,
  774. outputFolder: 'docs/interviews',
  775. autoOpenBrowser: true,
  776. });
  777. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  778. const output = { parts: [] as Array<{ type: string; text?: string }> };
  779. await service.handleCommandExecuteBefore(
  780. {
  781. command: 'interview',
  782. sessionID: 'session-nested',
  783. arguments: 'Nested Path Idea',
  784. },
  785. output,
  786. );
  787. // Check that file was created in nested folder
  788. const nestedDir = path.join(tempDir, 'docs', 'interviews');
  789. const files = await fs.readdir(nestedDir);
  790. expect(files.length).toBe(1);
  791. expect(files[0]).toBe('nested-path-idea.md');
  792. // Cleanup
  793. await fs.rm(tempDir, { recursive: true, force: true });
  794. });
  795. });
  796. describe('resuming with existing markdown file', () => {
  797. test('resumes existing file and sends resume prompt instead of kickoff', async () => {
  798. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  799. const ctx = createMockContext({ directory: tempDir });
  800. // Pre-create an existing interview file
  801. const interviewDir = path.join(tempDir, 'interview');
  802. await fs.mkdir(interviewDir, { recursive: true });
  803. const existingFilePath = path.join(interviewDir, 'existing-idea.md');
  804. await fs.writeFile(
  805. existingFilePath,
  806. '# Existing Idea\n\n## Current spec\n\nExisting spec content.\n\n## Q&A history\n\nQ: What platform?\nA: Web\n',
  807. 'utf8',
  808. );
  809. const service = createInterviewService(ctx);
  810. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  811. const output = { parts: [] as Array<{ type: string; text?: string }> };
  812. // Resume by referencing the existing file basename
  813. await service.handleCommandExecuteBefore(
  814. {
  815. command: 'interview',
  816. sessionID: 'session-resume',
  817. arguments: 'existing-idea',
  818. },
  819. output,
  820. );
  821. // Should send resume prompt (references existing document)
  822. const outputText = extractOutputText(output);
  823. expect(outputText).toContain('Resume the interview');
  824. expect(outputText).toContain('Existing Idea');
  825. expect(outputText).toContain('Existing spec content');
  826. // Should NOT send kickoff prompt
  827. expect(outputText).not.toContain(
  828. 'You are running an interview q&a session',
  829. );
  830. expect(outputText).not.toContain('Initial idea:');
  831. // Cleanup
  832. await fs.rm(tempDir, { recursive: true, force: true });
  833. });
  834. test('resumes by full relative path to existing file', async () => {
  835. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  836. const ctx = createMockContext({ directory: tempDir });
  837. // Pre-create an existing interview file in custom location
  838. const customDir = path.join(tempDir, 'docs');
  839. await fs.mkdir(customDir, { recursive: true });
  840. const existingFilePath = path.join(customDir, 'my-project.md');
  841. await fs.writeFile(
  842. existingFilePath,
  843. '# My Project\n\n## Current spec\n\nProject spec here.\n\n## Q&A history\n\nNo answers yet.\n',
  844. 'utf8',
  845. );
  846. const service = createInterviewService(ctx);
  847. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  848. const output = { parts: [] as Array<{ type: string; text?: string }> };
  849. // Resume by referencing the relative path
  850. await service.handleCommandExecuteBefore(
  851. {
  852. command: 'interview',
  853. sessionID: 'session-resume-path',
  854. arguments: 'docs/my-project.md',
  855. },
  856. output,
  857. );
  858. // Should send resume prompt
  859. const outputText = extractOutputText(output);
  860. expect(outputText).toContain('Resume the interview');
  861. expect(outputText).toContain('My Project');
  862. // Cleanup
  863. await fs.rm(tempDir, { recursive: true, force: true });
  864. });
  865. test('reuses same file when resuming multiple times', async () => {
  866. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  867. const ctx = createMockContext({ directory: tempDir });
  868. // Pre-create an existing interview file
  869. const interviewDir = path.join(tempDir, 'interview');
  870. await fs.mkdir(interviewDir, { recursive: true });
  871. const existingFilePath = path.join(interviewDir, 'reusable.md');
  872. await fs.writeFile(
  873. existingFilePath,
  874. '# Reusable Interview\n\n## Current spec\n\nOriginal content.\n\n## Q&A history\n\n',
  875. 'utf8',
  876. );
  877. const service = createInterviewService(ctx);
  878. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  879. // First resume
  880. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  881. await service.handleCommandExecuteBefore(
  882. {
  883. command: 'interview',
  884. sessionID: 'session-reuse-1',
  885. arguments: 'reusable',
  886. },
  887. output1,
  888. );
  889. const interviewId1 = extractInterviewIdFromLastPrompt(
  890. ctx.client.session.prompt,
  891. );
  892. // Second resume (different session, same file)
  893. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  894. await service.handleCommandExecuteBefore(
  895. {
  896. command: 'interview',
  897. sessionID: 'session-reuse-2',
  898. arguments: 'reusable',
  899. },
  900. output2,
  901. );
  902. const interviewId2 = extractInterviewIdFromLastPrompt(
  903. ctx.client.session.prompt,
  904. );
  905. // Both should reference the same file
  906. const state1 = await service.getInterviewState(
  907. requireInterviewId(interviewId1),
  908. );
  909. const state2 = await service.getInterviewState(
  910. requireInterviewId(interviewId2),
  911. );
  912. expect(state1.markdownPath).toBe(state2.markdownPath);
  913. expect(state1.markdownPath).toContain('reusable.md');
  914. // Cleanup
  915. await fs.rm(tempDir, { recursive: true, force: true });
  916. });
  917. });
  918. describe('configurable maxQuestions', () => {
  919. test('kickoff prompt references configured maxQuestions count', async () => {
  920. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  921. const ctx = createMockContext({ directory: tempDir });
  922. // Create service with custom maxQuestions
  923. const service = createInterviewService(ctx, {
  924. maxQuestions: 5,
  925. outputFolder: 'interview',
  926. autoOpenBrowser: true,
  927. });
  928. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  929. const output = { parts: [] as Array<{ type: string; text?: string }> };
  930. await service.handleCommandExecuteBefore(
  931. {
  932. command: 'interview',
  933. sessionID: 'session-max-q',
  934. arguments: 'Max Questions Test',
  935. },
  936. output,
  937. );
  938. // Kickoff prompt should reference the configured maxQuestions
  939. const outputText = extractOutputText(output);
  940. expect(outputText).toContain('at most 5 questions');
  941. expect(outputText).toContain('Return 0 to 5 questions');
  942. expect(outputText).toContain('Do not ask more than 5 questions');
  943. // Cleanup
  944. await fs.rm(tempDir, { recursive: true, force: true });
  945. });
  946. test('resume prompt references configured maxQuestions count', async () => {
  947. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  948. const ctx = createMockContext({ directory: tempDir });
  949. // Pre-create an existing file to trigger resume
  950. const interviewDir = path.join(tempDir, 'interview');
  951. await fs.mkdir(interviewDir, { recursive: true });
  952. await fs.writeFile(
  953. path.join(interviewDir, 'resume-max.md'),
  954. '# Resume Max\n\n## Current spec\n\nSpec.\n\n## Q&A history\n\n',
  955. 'utf8',
  956. );
  957. // Create service with custom maxQuestions
  958. const service = createInterviewService(ctx, {
  959. maxQuestions: 3,
  960. outputFolder: 'interview',
  961. autoOpenBrowser: true,
  962. });
  963. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  964. const output = { parts: [] as Array<{ type: string; text?: string }> };
  965. await service.handleCommandExecuteBefore(
  966. {
  967. command: 'interview',
  968. sessionID: 'session-resume-max',
  969. arguments: 'resume-max',
  970. },
  971. output,
  972. );
  973. // Resume prompt should reference the configured maxQuestions
  974. const outputText = extractOutputText(output);
  975. expect(outputText).toContain('up to 3 at a time');
  976. // Cleanup
  977. await fs.rm(tempDir, { recursive: true, force: true });
  978. });
  979. test('state exposes at most configured maxQuestions questions', async () => {
  980. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  981. // Start with empty messages
  982. const messagesData: Array<{
  983. info?: { role: string };
  984. parts?: Array<{ type: string; text?: string }>;
  985. }> = [];
  986. const ctx = createMockContext({
  987. directory: tempDir,
  988. messagesData,
  989. });
  990. // Create service with maxQuestions = 2
  991. const service = createInterviewService(ctx, {
  992. maxQuestions: 2,
  993. outputFolder: 'interview',
  994. autoOpenBrowser: true,
  995. });
  996. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  997. const output = { parts: [] as Array<{ type: string; text?: string }> };
  998. // Create interview first (baseMessageCount = 0)
  999. await service.handleCommandExecuteBefore(
  1000. {
  1001. command: 'interview',
  1002. sessionID: 'session-parse-max',
  1003. arguments: 'Parse Max Test',
  1004. },
  1005. output,
  1006. );
  1007. const interviewId = extractInterviewIdFromLastPrompt(
  1008. ctx.client.session.prompt,
  1009. );
  1010. const requiredInterviewId = requireInterviewId(interviewId);
  1011. // Now add the agent response with more questions than maxQuestions
  1012. messagesData.push({
  1013. info: { role: 'assistant' },
  1014. parts: [
  1015. {
  1016. type: 'text',
  1017. text: 'Questions.\n<interview_state>\n{\n "summary": "Test",\n "questions": [\n {"id": "q-1", "question": "Q1?", "options": ["A", "B"]},\n {"id": "q-2", "question": "Q2?", "options": ["A", "B"]},\n {"id": "q-3", "question": "Q3?", "options": ["A", "B"]},\n {"id": "q-4", "question": "Q4?", "options": ["A", "B"]}\n ]\n}\n</interview_state>',
  1018. },
  1019. ],
  1020. });
  1021. // State should only expose at most maxQuestions questions
  1022. const state = await service.getInterviewState(requiredInterviewId);
  1023. expect(state.questions.length).toBeLessThanOrEqual(2);
  1024. expect(state.questions.length).toBe(2);
  1025. // Cleanup
  1026. await fs.rm(tempDir, { recursive: true, force: true });
  1027. });
  1028. test('answer prompt references configured maxQuestions count', async () => {
  1029. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1030. // Start with empty messages
  1031. const messagesData: Array<{
  1032. info?: { role: string };
  1033. parts?: Array<{ type: string; text?: string }>;
  1034. }> = [];
  1035. const ctx = createMockContext({
  1036. directory: tempDir,
  1037. messagesData,
  1038. });
  1039. // Create service with custom maxQuestions
  1040. const service = createInterviewService(ctx, {
  1041. maxQuestions: 4,
  1042. outputFolder: 'interview',
  1043. autoOpenBrowser: true,
  1044. });
  1045. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1046. // Create interview first (baseMessageCount = 0)
  1047. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1048. await service.handleCommandExecuteBefore(
  1049. {
  1050. command: 'interview',
  1051. sessionID: 'session-answer-max',
  1052. arguments: 'Answer Max Test',
  1053. },
  1054. output,
  1055. );
  1056. const interviewId = extractInterviewIdFromLastPrompt(
  1057. ctx.client.session.prompt,
  1058. );
  1059. const requiredInterviewId = requireInterviewId(interviewId);
  1060. // Now add the agent response with a question
  1061. messagesData.push({
  1062. info: { role: 'assistant' },
  1063. parts: [
  1064. {
  1065. type: 'text',
  1066. text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1067. },
  1068. ],
  1069. });
  1070. // Clear previous prompt calls to capture the answer prompt
  1071. ctx.client.session.promptAsync.mock.calls.length = 0;
  1072. // Submit an answer
  1073. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'A' }];
  1074. await service.submitAnswers(requiredInterviewId, answers);
  1075. // Answer prompt should reference the configured maxQuestions
  1076. const lastPromptText = getPromptTexts(
  1077. ctx.client.session.promptAsync,
  1078. ).join('\n');
  1079. expect(lastPromptText).toContain('Return 0 to 4 questions');
  1080. // Cleanup
  1081. await fs.rm(tempDir, { recursive: true, force: true });
  1082. });
  1083. test('reuses observed session model when submitting interview answers', async () => {
  1084. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1085. const messagesData: Array<{
  1086. info?: { role: string };
  1087. parts?: Array<{ type: string; text?: string }>;
  1088. }> = [];
  1089. const ctx = createMockContext({
  1090. directory: tempDir,
  1091. messagesData,
  1092. });
  1093. const service = createInterviewService(ctx);
  1094. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1095. const sessionID = 'session-answer-model';
  1096. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1097. await service.handleCommandExecuteBefore(
  1098. {
  1099. command: 'interview',
  1100. sessionID,
  1101. arguments: 'Answer Model Test',
  1102. },
  1103. output,
  1104. );
  1105. const interviewId = requireInterviewId(
  1106. extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
  1107. );
  1108. messagesData.push({
  1109. info: { role: 'assistant' },
  1110. parts: [
  1111. {
  1112. type: 'text',
  1113. text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1114. },
  1115. ],
  1116. });
  1117. await service.handleEvent({
  1118. event: {
  1119. type: 'message.updated',
  1120. properties: {
  1121. info: {
  1122. sessionID,
  1123. providerID: 'anthropic',
  1124. modelID: 'claude-sonnet-4-6',
  1125. },
  1126. },
  1127. },
  1128. });
  1129. ctx.client.session.promptAsync.mock.calls.length = 0;
  1130. await service.submitAnswers(interviewId, [
  1131. { questionId: 'q-1', answer: 'A' },
  1132. ]);
  1133. const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
  1134. expect(call.body.model).toEqual({
  1135. providerID: 'anthropic',
  1136. modelID: 'claude-sonnet-4-6',
  1137. });
  1138. await fs.rm(tempDir, { recursive: true, force: true });
  1139. });
  1140. });
  1141. describe('agent-provided title', () => {
  1142. test('renames file when assistant provides title in interview_state', async () => {
  1143. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1144. // Start with empty messages
  1145. const messagesData: Array<{
  1146. info?: { role: string };
  1147. parts?: Array<{ type: string; text?: string }>;
  1148. }> = [];
  1149. const ctx = createMockContext({
  1150. directory: tempDir,
  1151. messagesData,
  1152. });
  1153. const service = createInterviewService(ctx);
  1154. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1155. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1156. // Create interview with user's idea
  1157. await service.handleCommandExecuteBefore(
  1158. {
  1159. command: 'interview',
  1160. sessionID: 'session-title-test',
  1161. arguments: 'My Great App Idea With Long Description',
  1162. },
  1163. output,
  1164. );
  1165. // Initial file should use slugified user input
  1166. const interviewDir = path.join(tempDir, 'interview');
  1167. let files = await fs.readdir(interviewDir);
  1168. expect(files.length).toBe(1);
  1169. expect(files[0]).toBe('my-great-app-idea-with-long-description.md');
  1170. const interviewId = extractInterviewIdFromLastPrompt(
  1171. ctx.client.session.prompt,
  1172. );
  1173. const requiredInterviewId = requireInterviewId(interviewId);
  1174. // Now add agent response with a concise title
  1175. messagesData.push({
  1176. info: { role: 'assistant' },
  1177. parts: [
  1178. {
  1179. type: 'text',
  1180. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a task management app",\n "title": "task-manager",\n "questions": [{"id": "q-1", "question": "What platform?", "options": ["Web", "Mobile"]}]\n}\n</interview_state>',
  1181. },
  1182. ],
  1183. });
  1184. // Sync interview (this triggers the rename)
  1185. const state = await service.getInterviewState(requiredInterviewId);
  1186. // File should be renamed to use assistant-provided title
  1187. files = await fs.readdir(interviewDir);
  1188. expect(files.length).toBe(1);
  1189. expect(files[0]).toBe('task-manager.md');
  1190. expect(state.markdownPath).toContain('task-manager.md');
  1191. // Cleanup
  1192. await fs.rm(tempDir, { recursive: true, force: true });
  1193. });
  1194. test('keeps original filename when assistant omits title', async () => {
  1195. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1196. const messagesData: Array<{
  1197. info?: { role: string };
  1198. parts?: Array<{ type: string; text?: string }>;
  1199. }> = [];
  1200. const ctx = createMockContext({
  1201. directory: tempDir,
  1202. messagesData,
  1203. });
  1204. const service = createInterviewService(ctx);
  1205. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1206. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1207. await service.handleCommandExecuteBefore(
  1208. {
  1209. command: 'interview',
  1210. sessionID: 'session-no-title',
  1211. arguments: 'Simple Idea',
  1212. },
  1213. output,
  1214. );
  1215. const interviewDir = path.join(tempDir, 'interview');
  1216. let files = await fs.readdir(interviewDir);
  1217. expect(files[0]).toBe('simple-idea.md');
  1218. const interviewId = extractInterviewIdFromLastPrompt(
  1219. ctx.client.session.prompt,
  1220. );
  1221. const requiredInterviewId = requireInterviewId(interviewId);
  1222. // Agent response without title field
  1223. messagesData.push({
  1224. info: { role: 'assistant' },
  1225. parts: [
  1226. {
  1227. type: 'text',
  1228. text: 'Questions.\n<interview_state>\n{\n "summary": "Building an app",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1229. },
  1230. ],
  1231. });
  1232. const state = await service.getInterviewState(requiredInterviewId);
  1233. // Filename should remain unchanged
  1234. files = await fs.readdir(interviewDir);
  1235. expect(files[0]).toBe('simple-idea.md');
  1236. expect(state.markdownPath).toContain('simple-idea.md');
  1237. // Cleanup
  1238. await fs.rm(tempDir, { recursive: true, force: true });
  1239. });
  1240. test('does not rename if target filename already exists', async () => {
  1241. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1242. const messagesData: Array<{
  1243. info?: { role: string };
  1244. parts?: Array<{ type: string; text?: string }>;
  1245. }> = [];
  1246. const ctx = createMockContext({
  1247. directory: tempDir,
  1248. messagesData,
  1249. });
  1250. // Pre-create a file with the target name
  1251. const interviewDir = path.join(tempDir, 'interview');
  1252. await fs.mkdir(interviewDir, { recursive: true });
  1253. await fs.writeFile(
  1254. path.join(interviewDir, 'target-name.md'),
  1255. '# Existing\n\n## Current spec\n\nExisting.\n\n## Q&A history\n\n',
  1256. 'utf8',
  1257. );
  1258. const service = createInterviewService(ctx);
  1259. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1260. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1261. await service.handleCommandExecuteBefore(
  1262. {
  1263. command: 'interview',
  1264. sessionID: 'session-existing',
  1265. arguments: 'Original Idea',
  1266. },
  1267. output,
  1268. );
  1269. let files = await fs.readdir(interviewDir);
  1270. expect(files).toContain('original-idea.md');
  1271. expect(files).toContain('target-name.md');
  1272. const interviewId = extractInterviewIdFromLastPrompt(
  1273. ctx.client.session.prompt,
  1274. );
  1275. const requiredInterviewId = requireInterviewId(interviewId);
  1276. // Agent suggests a title that matches existing file
  1277. messagesData.push({
  1278. info: { role: 'assistant' },
  1279. parts: [
  1280. {
  1281. type: 'text',
  1282. text: 'Questions.\n<interview_state>\n{\n "summary": "Building an app",\n "title": "target-name",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1283. },
  1284. ],
  1285. });
  1286. const state = await service.getInterviewState(requiredInterviewId);
  1287. // Should not rename (would overwrite existing file)
  1288. files = await fs.readdir(interviewDir);
  1289. expect(files).toContain('original-idea.md');
  1290. expect(files).toContain('target-name.md');
  1291. expect(state.markdownPath).toContain('original-idea.md');
  1292. // Cleanup
  1293. await fs.rm(tempDir, { recursive: true, force: true });
  1294. });
  1295. });
  1296. describe('autoOpenBrowser config', () => {
  1297. test('does not open a browser during automated test runtimes', async () => {
  1298. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1299. const ctx = createMockContext({ directory: tempDir });
  1300. const { service, openBrowserMock } = createTestService(
  1301. ctx,
  1302. {
  1303. maxQuestions: 2,
  1304. outputFolder: 'interview',
  1305. autoOpenBrowser: true,
  1306. },
  1307. {
  1308. env: createRuntimeEnv({
  1309. NODE_ENV: 'test',
  1310. CI: '0',
  1311. }),
  1312. },
  1313. );
  1314. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1315. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1316. await service.handleCommandExecuteBefore(
  1317. {
  1318. command: 'interview',
  1319. sessionID: 'session-browser-test-env',
  1320. arguments: 'Browser Test Env',
  1321. },
  1322. output,
  1323. );
  1324. expect(openBrowserMock).not.toHaveBeenCalled();
  1325. await fs.rm(tempDir, { recursive: true, force: true });
  1326. });
  1327. test('does not open a browser in CI even when auto-open is enabled', async () => {
  1328. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1329. const ctx = createMockContext({ directory: tempDir });
  1330. const { service, openBrowserMock } = createTestService(
  1331. ctx,
  1332. {
  1333. maxQuestions: 2,
  1334. outputFolder: 'interview',
  1335. autoOpenBrowser: true,
  1336. },
  1337. {
  1338. env: createRuntimeEnv({
  1339. CI: 'true',
  1340. }),
  1341. },
  1342. );
  1343. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1344. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1345. await service.handleCommandExecuteBefore(
  1346. {
  1347. command: 'interview',
  1348. sessionID: 'session-browser-ci-env',
  1349. arguments: 'Browser CI Env',
  1350. },
  1351. output,
  1352. );
  1353. expect(openBrowserMock).not.toHaveBeenCalled();
  1354. await fs.rm(tempDir, { recursive: true, force: true });
  1355. });
  1356. test('uses injected browser opener instead of opening a real browser in tests', async () => {
  1357. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1358. const ctx = createMockContext({ directory: tempDir });
  1359. const { service, openBrowserMock } = createTestService(
  1360. ctx,
  1361. {
  1362. maxQuestions: 2,
  1363. outputFolder: 'interview',
  1364. autoOpenBrowser: true,
  1365. },
  1366. {
  1367. env: createRuntimeEnv({
  1368. NODE_ENV: 'development',
  1369. CI: '0',
  1370. }),
  1371. },
  1372. );
  1373. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1374. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1375. await service.handleCommandExecuteBefore(
  1376. {
  1377. command: 'interview',
  1378. sessionID: 'session-browser-open',
  1379. arguments: 'Browser Open Test',
  1380. },
  1381. output,
  1382. );
  1383. expect(openBrowserMock).toHaveBeenCalledTimes(1);
  1384. await fs.rm(tempDir, { recursive: true, force: true });
  1385. });
  1386. test('kickoff prompt includes title field guidance', async () => {
  1387. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1388. const ctx = createMockContext({ directory: tempDir });
  1389. const service = createInterviewService(ctx, {
  1390. maxQuestions: 2,
  1391. outputFolder: 'interview',
  1392. autoOpenBrowser: true,
  1393. });
  1394. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1395. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1396. await service.handleCommandExecuteBefore(
  1397. {
  1398. command: 'interview',
  1399. sessionID: 'session-browser-config',
  1400. arguments: 'Browser Config Test',
  1401. },
  1402. output,
  1403. );
  1404. // Kickoff prompt should mention title field
  1405. const outputText = extractOutputText(output);
  1406. expect(outputText).toContain('"title":');
  1407. expect(outputText).toContain('concise-kebab-case-title-for-filename');
  1408. // Cleanup
  1409. await fs.rm(tempDir, { recursive: true, force: true });
  1410. });
  1411. });
  1412. });
  1413. describe('renderInterviewPage', () => {
  1414. test('escapes HTML special characters in interviewId for title', () => {
  1415. const maliciousId = '<script>alert("xss")</script>';
  1416. const html = renderInterviewPage(maliciousId, maliciousId);
  1417. // Should not contain raw script tags in title
  1418. expect(html).not.toContain('<title>Interview <script>');
  1419. // Should contain escaped version in title
  1420. expect(html).toContain(
  1421. '<title>Interview &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</title>',
  1422. );
  1423. });
  1424. test('escapes ampersand in interviewId', () => {
  1425. const idWithAmpersand = 'A&B Test';
  1426. const html = renderInterviewPage(idWithAmpersand, idWithAmpersand);
  1427. expect(html).toContain('<title>Interview A&amp;B Test</title>');
  1428. expect(html).not.toContain('<title>Interview A&B Test</title>');
  1429. });
  1430. test('escapes single quotes in interviewId', () => {
  1431. const idWithQuote = "test'quote";
  1432. const html = renderInterviewPage(idWithQuote, idWithQuote);
  1433. expect(html).toContain('<title>Interview test&#39;quote</title>');
  1434. });
  1435. test('preserves safe interviewId characters', () => {
  1436. const safeId = 'my-interview-123_test';
  1437. const html = renderInterviewPage(safeId, safeId);
  1438. expect(html).toContain(`<title>Interview ${safeId}</title>`);
  1439. });
  1440. test('interviewId in JSON script tag is properly stringified', () => {
  1441. const idWithQuotes = 'test"onclick"evil';
  1442. const html = renderInterviewPage(idWithQuotes, idWithQuotes);
  1443. // The interviewId in the JavaScript should be JSON.stringify'd
  1444. // JSON.stringify escapes quotes as \"
  1445. expect(html).toContain('const interviewId = ');
  1446. // The actual output has escaped quotes for JavaScript string
  1447. expect(html).toContain('"test\\"onclick\\"evil"');
  1448. });
  1449. test('does not inject raw interviewId into HTML title', () => {
  1450. const xssAttempt = '<img src=x onerror=alert(1)>';
  1451. const html = renderInterviewPage(xssAttempt, xssAttempt);
  1452. // Title should be escaped
  1453. expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
  1454. expect(html).toContain(
  1455. '<title>Interview &lt;img src=x onerror=alert(1)&gt;</title>',
  1456. );
  1457. });
  1458. test('renders the hosted brand logo', () => {
  1459. const html = renderInterviewPage('brand-test', 'brand-test');
  1460. expect(html).toContain('<img class="brand-mark"');
  1461. expect(html).toContain(
  1462. 'https://ohmyopencodeslim.com/android-chrome-512x512.png',
  1463. );
  1464. });
  1465. test('shows explicit Enter guidance for option questions', () => {
  1466. const html = renderInterviewPage('enter-hint-test', 'enter-hint-test');
  1467. expect(html).toContain('.question-hint {');
  1468. expect(html).toContain('.hint-chip {');
  1469. expect(html).toContain(
  1470. '<kbd>Enter</kbd><span>Accept selected answer</span>',
  1471. );
  1472. expect(html).toContain('<kbd>1-9</kbd><span>Choose an option</span>');
  1473. });
  1474. });
  1475. /** Discover a free port by briefly binding to port 0, then closing. */
  1476. async function findFreePort(): Promise<number> {
  1477. return new Promise((resolve, reject) => {
  1478. const srv = createServer();
  1479. srv.listen(0, '127.0.0.1', () => {
  1480. const addr = srv.address();
  1481. srv.close(() => {
  1482. if (!addr || typeof addr === 'string') {
  1483. reject(new Error('Failed to find free port'));
  1484. return;
  1485. }
  1486. resolve(addr.port);
  1487. });
  1488. });
  1489. srv.on('error', reject);
  1490. });
  1491. }
  1492. describe('interview server port configuration', () => {
  1493. const noopDeps = {
  1494. getState: mock(
  1495. async (_id: string) =>
  1496. ({
  1497. interview: {
  1498. id: 'x',
  1499. idea: 'x',
  1500. status: 'active',
  1501. markdownPath: 'x',
  1502. },
  1503. questions: [],
  1504. mode: 'awaiting-agent' as const,
  1505. isBusy: false,
  1506. }) as any,
  1507. ),
  1508. listInterviewFiles: mock(async () => []),
  1509. listInterviews: mock(() => []),
  1510. submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
  1511. handleNudgeAction: mock(
  1512. async (_id: string, _action: 'more-questions' | 'confirm-complete') => {},
  1513. ),
  1514. outputFolder: 'interview',
  1515. };
  1516. test('server starts on a specific port when port is non-zero', async () => {
  1517. const freePort = await findFreePort();
  1518. const server = createInterviewServer({ ...noopDeps, port: freePort });
  1519. try {
  1520. const baseUrl = await server.ensureStarted();
  1521. expect(baseUrl).toBe(`http://127.0.0.1:${freePort}`);
  1522. } finally {
  1523. server.close();
  1524. }
  1525. });
  1526. test('server starts on a random port when port is 0', async () => {
  1527. const server = createInterviewServer({ ...noopDeps, port: 0 });
  1528. try {
  1529. const baseUrl = await server.ensureStarted();
  1530. expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
  1531. const portStr = baseUrl.split(':').pop();
  1532. const port = Number.parseInt(portStr ?? '0', 10);
  1533. expect(port).toBeGreaterThan(0);
  1534. } finally {
  1535. server.close();
  1536. }
  1537. });
  1538. test('baseUrl contains the correct port number for fixed port', async () => {
  1539. const freePort = await findFreePort();
  1540. const server = createInterviewServer({ ...noopDeps, port: freePort });
  1541. try {
  1542. const baseUrl = await server.ensureStarted();
  1543. const portStr = baseUrl.split(':').pop();
  1544. const port = Number.parseInt(portStr ?? '0', 10);
  1545. expect(port).toBe(freePort);
  1546. } finally {
  1547. server.close();
  1548. }
  1549. });
  1550. test('baseUrl contains a valid port number for random port', async () => {
  1551. const server = createInterviewServer({ ...noopDeps, port: 0 });
  1552. try {
  1553. const baseUrl = await server.ensureStarted();
  1554. const portStr = baseUrl.split(':').pop();
  1555. const port = Number.parseInt(portStr ?? '0', 10);
  1556. expect(port).toBeGreaterThanOrEqual(1);
  1557. expect(port).toBeLessThanOrEqual(65535);
  1558. } finally {
  1559. server.close();
  1560. }
  1561. });
  1562. test('rejects with friendly error when port is already in use', async () => {
  1563. // Occupy a port first
  1564. const blocker = createServer();
  1565. const occupiedPort = await new Promise<number>((resolve, reject) => {
  1566. blocker.listen(0, '127.0.0.1', () => {
  1567. const addr = blocker.address();
  1568. if (!addr || typeof addr === 'string') {
  1569. reject(new Error('Failed to bind blocker'));
  1570. return;
  1571. }
  1572. resolve(addr.port);
  1573. });
  1574. blocker.on('error', reject);
  1575. });
  1576. const server = createInterviewServer({
  1577. ...noopDeps,
  1578. port: occupiedPort,
  1579. });
  1580. try {
  1581. await expect(server.ensureStarted()).rejects.toThrow(
  1582. `Interview server port ${occupiedPort} is already in use`,
  1583. );
  1584. } finally {
  1585. server.close();
  1586. blocker.close();
  1587. }
  1588. });
  1589. });
  1590. describe('InterviewConfigSchema port validation', () => {
  1591. test('accepts valid port 0', () => {
  1592. const result = InterviewConfigSchema.parse({ port: 0 });
  1593. expect(result.port).toBe(0);
  1594. });
  1595. test('accepts valid port 8080', () => {
  1596. const result = InterviewConfigSchema.parse({ port: 8080 });
  1597. expect(result.port).toBe(8080);
  1598. });
  1599. test('accepts valid port 65535', () => {
  1600. const result = InterviewConfigSchema.parse({ port: 65535 });
  1601. expect(result.port).toBe(65535);
  1602. });
  1603. test('defaults port to 0 when omitted', () => {
  1604. const result = InterviewConfigSchema.parse({});
  1605. expect(result.port).toBe(0);
  1606. });
  1607. test('rejects negative port', () => {
  1608. expect(() => InterviewConfigSchema.parse({ port: -1 })).toThrow();
  1609. });
  1610. test('rejects port above 65535', () => {
  1611. expect(() => InterviewConfigSchema.parse({ port: 70000 })).toThrow();
  1612. });
  1613. test('rejects float port', () => {
  1614. expect(() => InterviewConfigSchema.parse({ port: 3.5 })).toThrow();
  1615. });
  1616. });
  1617. describe('interview service abandoned-record retention', () => {
  1618. const RETENTION_CAP = MAX_RETAINED_ABANDONED;
  1619. async function createInterviewOnSession(
  1620. service: ReturnType<typeof createInterviewService>,
  1621. ctx: ReturnType<typeof createMockContext>,
  1622. index: number,
  1623. ): Promise<string> {
  1624. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1625. await service.handleCommandExecuteBefore(
  1626. {
  1627. command: 'interview',
  1628. sessionID: `session-${index}`,
  1629. arguments: `Idea ${index}`,
  1630. },
  1631. output,
  1632. );
  1633. return requireInterviewId(
  1634. extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
  1635. );
  1636. }
  1637. test('evicts oldest abandoned records once the retention cap is exceeded', async () => {
  1638. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1639. try {
  1640. const ctx = createMockContext({ directory: tempDir });
  1641. const service = createInterviewService(ctx);
  1642. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1643. const ids: string[] = [];
  1644. for (let i = 0; i < RETENTION_CAP + 2; i++) {
  1645. ids.push(await createInterviewOnSession(service, ctx, i));
  1646. // Deleting the session abandons the interview, triggering pruning.
  1647. await service.handleEvent({
  1648. event: {
  1649. type: 'session.deleted',
  1650. properties: { sessionID: `session-${i}` },
  1651. },
  1652. });
  1653. }
  1654. // The two oldest abandoned records are evicted from the registry.
  1655. await expect(service.getInterviewState(ids[0])).rejects.toThrow(
  1656. 'Interview not found',
  1657. );
  1658. await expect(service.getInterviewState(ids[1])).rejects.toThrow(
  1659. 'Interview not found',
  1660. );
  1661. // The most recent abandoned record is retained and still renders.
  1662. const retained = await service.getInterviewState(ids[ids.length - 1]);
  1663. expect(retained.mode).toBe('abandoned');
  1664. } finally {
  1665. await fs.rm(tempDir, { recursive: true, force: true });
  1666. }
  1667. });
  1668. test('prunes by abandonment order instead of creation order', async () => {
  1669. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1670. try {
  1671. const ctx = createMockContext({ directory: tempDir });
  1672. const service = createInterviewService(ctx);
  1673. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1674. const oldActiveId = await createInterviewOnSession(service, ctx, 0);
  1675. const abandonedIds: string[] = [];
  1676. for (let i = 1; i <= RETENTION_CAP; i++) {
  1677. const id = await createInterviewOnSession(service, ctx, i);
  1678. abandonedIds.push(id);
  1679. await service.handleEvent({
  1680. event: {
  1681. type: 'session.deleted',
  1682. properties: { sessionID: `session-${i}` },
  1683. },
  1684. });
  1685. }
  1686. // Abandoning the old active interview after the cap is full should retain
  1687. // that newly abandoned record and prune the earliest previously abandoned
  1688. // record. Its older createdAt must not make it the eviction candidate.
  1689. await service.handleEvent({
  1690. event: {
  1691. type: 'session.deleted',
  1692. properties: { sessionID: 'session-0' },
  1693. },
  1694. });
  1695. await expect(service.getInterviewState(abandonedIds[0])).rejects.toThrow(
  1696. 'Interview not found',
  1697. );
  1698. const oldActiveState = await service.getInterviewState(oldActiveId);
  1699. expect(oldActiveState.mode).toBe('abandoned');
  1700. const latestPreviouslyAbandoned = await service.getInterviewState(
  1701. abandonedIds[abandonedIds.length - 1],
  1702. );
  1703. expect(latestPreviouslyAbandoned.mode).toBe('abandoned');
  1704. } finally {
  1705. await fs.rm(tempDir, { recursive: true, force: true });
  1706. }
  1707. });
  1708. test('retains abandoned records that stay within the cap', async () => {
  1709. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1710. try {
  1711. const ctx = createMockContext({ directory: tempDir });
  1712. const service = createInterviewService(ctx);
  1713. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1714. const id = await createInterviewOnSession(service, ctx, 0);
  1715. await service.handleEvent({
  1716. event: {
  1717. type: 'session.deleted',
  1718. properties: { sessionID: 'session-0' },
  1719. },
  1720. });
  1721. // Below the cap, the abandoned record is kept so an open tab can still
  1722. // render its final state.
  1723. const state = await service.getInterviewState(id);
  1724. expect(state.mode).toBe('abandoned');
  1725. } finally {
  1726. await fs.rm(tempDir, { recursive: true, force: true });
  1727. }
  1728. });
  1729. });