interview.test.ts 63 KB

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