interview.test.ts 71 KB

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