interview.test.ts 62 KB

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