interview.test.ts 71 KB

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