interview.test.ts 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509
  1. import { describe, expect, mock, test } from 'bun:test';
  2. import * as fs from 'node:fs/promises';
  3. import * as path from 'node:path';
  4. import { createInterviewService as createRealInterviewService } from './service';
  5. import type { InterviewAnswer } from './types';
  6. import { renderInterviewPage } from './ui';
  7. // Mock the plugin context with mutable message array
  8. function createMockContext(overrides?: {
  9. directory?: string;
  10. messagesData?: Array<{
  11. info?: { role: string };
  12. parts?: Array<{ type: string; text?: string }>;
  13. }>;
  14. promptImpl?: (args: any) => Promise<unknown>;
  15. }) {
  16. // Use a mutable array that can be updated after creation
  17. const messagesData = overrides?.messagesData ?? [];
  18. return {
  19. client: {
  20. session: {
  21. messages: mock(async () => ({ data: messagesData })),
  22. prompt: mock(async (args: any) => {
  23. if (overrides?.promptImpl) {
  24. return await overrides.promptImpl(args);
  25. }
  26. return {};
  27. }),
  28. },
  29. },
  30. directory: overrides?.directory ?? '/test/directory',
  31. } as any;
  32. }
  33. // Helper to extract text from prompt calls
  34. function getPromptTexts(promptMock: {
  35. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  36. }): string[] {
  37. return promptMock.mock.calls
  38. .map((call) => call[0].body?.parts?.[0]?.text ?? '')
  39. .filter(Boolean);
  40. }
  41. // Helper to extract interview ID from the last prompt call
  42. function extractInterviewIdFromLastPrompt(promptMock: {
  43. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  44. }): string | null {
  45. const calls = promptMock.mock.calls;
  46. if (calls.length === 0) return null;
  47. // Get the last call
  48. const lastCall = calls[calls.length - 1];
  49. const text = lastCall[0].body?.parts?.[0]?.text ?? '';
  50. const match = text.match(/interview\/([^\s]+)/);
  51. return match ? match[1] : null;
  52. }
  53. // Helper to extract text from output parts (kickoff/resume prompts go here)
  54. function extractOutputText(output: {
  55. parts: Array<{ type: string; text?: string }>;
  56. }): string {
  57. const textPart = output.parts.find((part) => part.type === 'text');
  58. return textPart?.text ?? '';
  59. }
  60. function requireInterviewId(value: string | null): string {
  61. expect(value).not.toBeNull();
  62. return value as string;
  63. }
  64. function createInterviewService(
  65. ctx: ReturnType<typeof createMockContext>,
  66. config?: Parameters<typeof createRealInterviewService>[1],
  67. ) {
  68. return createRealInterviewService(ctx, config, {
  69. openBrowser: mock((_url: string) => {}),
  70. });
  71. }
  72. function createTestService(
  73. ctx: ReturnType<typeof createMockContext>,
  74. config?: Parameters<typeof createRealInterviewService>[1],
  75. ) {
  76. const openBrowserMock = mock((_url: string) => {});
  77. const service = createRealInterviewService(ctx, config, {
  78. openBrowser: openBrowserMock,
  79. });
  80. return {
  81. service,
  82. openBrowserMock,
  83. };
  84. }
  85. describe('interview service', () => {
  86. describe('/interview <idea> command', () => {
  87. test('creates interview and sends kickoff prompt with UI notification', async () => {
  88. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  89. const ctx = createMockContext({ directory: tempDir });
  90. const service = createInterviewService(ctx);
  91. // Set up base URL resolver to avoid server error
  92. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  93. const output = { parts: [] as Array<{ type: string; text?: string }> };
  94. await service.handleCommandExecuteBefore(
  95. {
  96. command: 'interview',
  97. sessionID: 'session-123',
  98. arguments: 'My App Idea',
  99. },
  100. output,
  101. );
  102. // Should inject kickoff prompt into output
  103. expect(output.parts.length).toBe(1);
  104. expect(output.parts[0].type).toBe('text');
  105. expect(output.parts[0].text).toContain('My App Idea');
  106. expect(output.parts[0].text).toContain('<interview_state>');
  107. // Should send UI notification prompt to session
  108. expect(ctx.client.session.prompt).toHaveBeenCalled();
  109. const promptTexts = getPromptTexts(ctx.client.session.prompt);
  110. expect(
  111. promptTexts.some((text) => text.includes('Interview UI ready')),
  112. ).toBe(true);
  113. expect(promptTexts.some((text) => text.includes('/interview/'))).toBe(
  114. true,
  115. );
  116. // Cleanup
  117. await fs.rm(tempDir, { recursive: true, force: true });
  118. });
  119. test('creates markdown file with slug-only filename (no timestamp prefix)', async () => {
  120. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  121. const ctx = createMockContext({ directory: tempDir });
  122. const service = createInterviewService(ctx);
  123. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  124. const output = { parts: [] as Array<{ type: string; text?: string }> };
  125. await service.handleCommandExecuteBefore(
  126. {
  127. command: 'interview',
  128. sessionID: 'session-456',
  129. arguments: 'Test Idea',
  130. },
  131. output,
  132. );
  133. // Check that interview directory and file were created
  134. const interviewDir = path.join(tempDir, 'interview');
  135. const files = await fs.readdir(interviewDir);
  136. expect(files.length).toBe(1);
  137. // Filename should be slug-only, no timestamp prefix
  138. expect(files[0]).toBe('test-idea.md');
  139. expect(files[0]).not.toMatch(/^\d+-/);
  140. // Check file content structure
  141. const content = await fs.readFile(
  142. path.join(interviewDir, files[0]),
  143. 'utf8',
  144. );
  145. expect(content).toContain('# Test Idea');
  146. expect(content).toContain('## Current spec');
  147. expect(content).toContain('## Q&A history');
  148. // Cleanup
  149. await fs.rm(tempDir, { recursive: true, force: true });
  150. });
  151. });
  152. describe('answer submission', () => {
  153. test('appends only Q/A history to markdown document', async () => {
  154. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  155. // Start with empty messages, then add questions after interview creation
  156. const messagesData: Array<{
  157. info?: { role: string };
  158. parts?: Array<{ type: string; text?: string }>;
  159. }> = [];
  160. const ctx = createMockContext({
  161. directory: tempDir,
  162. messagesData,
  163. });
  164. const service = createInterviewService(ctx);
  165. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  166. // Create interview first (with empty messages, so baseMessageCount = 0)
  167. const output = { parts: [] as Array<{ type: string; text?: string }> };
  168. await service.handleCommandExecuteBefore(
  169. {
  170. command: 'interview',
  171. sessionID: 'session-789',
  172. arguments: 'Platform App',
  173. },
  174. output,
  175. );
  176. // Get the interview ID from the prompt calls
  177. const interviewId = extractInterviewIdFromLastPrompt(
  178. ctx.client.session.prompt,
  179. );
  180. const requiredInterviewId = requireInterviewId(interviewId);
  181. // Now add the questions to messages (simulating agent response)
  182. messagesData.push({
  183. info: { role: 'assistant' },
  184. parts: [
  185. {
  186. type: 'text',
  187. 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>',
  188. },
  189. ],
  190. });
  191. // Submit an answer
  192. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'Web' }];
  193. await service.submitAnswers(requiredInterviewId, answers);
  194. // Read the markdown file
  195. const interviewDir = path.join(tempDir, 'interview');
  196. const files = await fs.readdir(interviewDir);
  197. const content = await fs.readFile(
  198. path.join(interviewDir, files[0]),
  199. 'utf8',
  200. );
  201. // Verify Q/A was appended to history section
  202. expect(content).toContain('## Q&A history');
  203. expect(content).toContain('Q: What platform?');
  204. expect(content).toContain('A: Web');
  205. // Verify the Current spec section exists (even if empty after submission)
  206. expect(content).toContain('## Current spec');
  207. // Cleanup
  208. await fs.rm(tempDir, { recursive: true, force: true });
  209. });
  210. test('preserves existing history when appending new answers', async () => {
  211. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  212. // Start with messages that include one answered question and one pending
  213. const messagesData: Array<{
  214. info?: { role: string };
  215. parts?: Array<{ type: string; text?: string }>;
  216. }> = [
  217. // First question and answer
  218. {
  219. info: { role: 'assistant' },
  220. parts: [
  221. {
  222. type: 'text',
  223. 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>',
  224. },
  225. ],
  226. },
  227. { info: { role: 'user' }, parts: [{ type: 'text', text: 'App1' }] },
  228. // Second question (current)
  229. {
  230. info: { role: 'assistant' },
  231. parts: [
  232. {
  233. type: 'text',
  234. 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>',
  235. },
  236. ],
  237. },
  238. ];
  239. const ctx = createMockContext({
  240. directory: tempDir,
  241. messagesData,
  242. });
  243. const service = createInterviewService(ctx);
  244. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  245. // Create interview (baseMessageCount will be 3)
  246. const output = { parts: [] as Array<{ type: string; text?: string }> };
  247. await service.handleCommandExecuteBefore(
  248. {
  249. command: 'interview',
  250. sessionID: 'session-abc',
  251. arguments: 'Multi Round App',
  252. },
  253. output,
  254. );
  255. // Get interview ID from prompt calls
  256. const interviewId = extractInterviewIdFromLastPrompt(
  257. ctx.client.session.prompt,
  258. );
  259. const requiredInterviewId = requireInterviewId(interviewId);
  260. // Add a new message simulating agent response after interview creation
  261. // This ensures baseMessageCount (3) < current messages length
  262. messagesData.push({
  263. info: { role: 'assistant' },
  264. parts: [
  265. {
  266. type: 'text',
  267. 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>',
  268. },
  269. ],
  270. });
  271. // Submit second answer (q-2 is the active question now)
  272. await service.submitAnswers(requiredInterviewId, [
  273. { questionId: 'q-2', answer: 'Blue' },
  274. ]);
  275. // Read file after submission
  276. const interviewDir = path.join(tempDir, 'interview');
  277. const files = await fs.readdir(interviewDir);
  278. const content = await fs.readFile(
  279. path.join(interviewDir, files[0]),
  280. 'utf8',
  281. );
  282. // Verify Q/A is in history
  283. expect(content).toContain('Q: What color?');
  284. expect(content).toContain('A: Blue');
  285. // Cleanup
  286. await fs.rm(tempDir, { recursive: true, force: true });
  287. });
  288. test('replaces placeholder history on first answer submission', async () => {
  289. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  290. const messagesData: Array<{
  291. info?: { role: string };
  292. parts?: Array<{ type: string; text?: string }>;
  293. }> = [];
  294. const ctx = createMockContext({
  295. directory: tempDir,
  296. messagesData,
  297. });
  298. const service = createInterviewService(ctx);
  299. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  300. const output = { parts: [] as Array<{ type: string; text?: string }> };
  301. await service.handleCommandExecuteBefore(
  302. {
  303. command: 'interview',
  304. sessionID: 'session-placeholder',
  305. arguments: 'Placeholder Test',
  306. },
  307. output,
  308. );
  309. const interviewId = extractInterviewIdFromLastPrompt(
  310. ctx.client.session.prompt,
  311. );
  312. const requiredInterviewId = requireInterviewId(interviewId);
  313. messagesData.push({
  314. info: { role: 'assistant' },
  315. parts: [
  316. {
  317. type: 'text',
  318. 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>',
  319. },
  320. ],
  321. });
  322. await service.submitAnswers(requiredInterviewId, [
  323. { questionId: 'q-1', answer: 'Web' },
  324. ]);
  325. const interviewDir = path.join(tempDir, 'interview');
  326. const files = await fs.readdir(interviewDir);
  327. const content = await fs.readFile(
  328. path.join(interviewDir, files[0]),
  329. 'utf8',
  330. );
  331. expect(content).not.toContain('## Q&A history\n\nNo answers yet.\n\nQ:');
  332. expect(content).toContain('## Q&A history\n\nQ: What platform?\nA: Web');
  333. await fs.rm(tempDir, { recursive: true, force: true });
  334. });
  335. test('rejects concurrent submission when first request holds busy lock', async () => {
  336. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  337. // Start with empty messages
  338. const messagesData: Array<{
  339. info?: { role: string };
  340. parts?: Array<{ type: string; text?: string }>;
  341. }> = [];
  342. // Create a prompt that delays to hold the lock
  343. let promptStarted = false;
  344. const ctx = createMockContext({
  345. directory: tempDir,
  346. messagesData,
  347. promptImpl: async () => {
  348. promptStarted = true;
  349. // Delay to hold the lock during test
  350. await new Promise((resolve) => setTimeout(resolve, 200));
  351. return {};
  352. },
  353. });
  354. const service = createInterviewService(ctx);
  355. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  356. // Create interview first (baseMessageCount = 0)
  357. const output = { parts: [] as Array<{ type: string; text?: string }> };
  358. await service.handleCommandExecuteBefore(
  359. {
  360. command: 'interview',
  361. sessionID: 'session-concurrent',
  362. arguments: 'Concurrent Test',
  363. },
  364. output,
  365. );
  366. const interviewId = extractInterviewIdFromLastPrompt(
  367. ctx.client.session.prompt,
  368. );
  369. const requiredInterviewId = requireInterviewId(interviewId);
  370. // Now add the agent response with questions
  371. messagesData.push({
  372. info: { role: 'assistant' },
  373. parts: [
  374. {
  375. type: 'text',
  376. 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>',
  377. },
  378. ],
  379. });
  380. // Start first submission (will hold lock due to slow prompt)
  381. const firstSubmissionPromise = service.submitAnswers(
  382. requiredInterviewId,
  383. [{ questionId: 'q-1', answer: 'Web' }],
  384. );
  385. // Wait for prompt to start (indicates lock is acquired)
  386. while (!promptStarted) {
  387. await new Promise((resolve) => setTimeout(resolve, 10));
  388. }
  389. // Second submission should be rejected immediately (busy lock held)
  390. await expect(
  391. service.submitAnswers(requiredInterviewId, [
  392. { questionId: 'q-1', answer: 'Mobile' },
  393. ]),
  394. ).rejects.toThrow('Interview session is busy');
  395. // Wait for first submission to complete (it will succeed after 200ms delay)
  396. await firstSubmissionPromise;
  397. // Cleanup
  398. await fs.rm(tempDir, { recursive: true, force: true });
  399. });
  400. test('busy lock released when validation fails after lock acquired', async () => {
  401. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  402. // Start with empty messages
  403. const messagesData: Array<{
  404. info?: { role: string };
  405. parts?: Array<{ type: string; text?: string }>;
  406. }> = [];
  407. const ctx = createMockContext({
  408. directory: tempDir,
  409. messagesData,
  410. });
  411. const service = createInterviewService(ctx);
  412. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  413. // Create interview first (baseMessageCount = 0)
  414. const output = { parts: [] as Array<{ type: string; text?: string }> };
  415. await service.handleCommandExecuteBefore(
  416. {
  417. command: 'interview',
  418. sessionID: 'session-retry',
  419. arguments: 'Retry Test',
  420. },
  421. output,
  422. );
  423. const interviewId = extractInterviewIdFromLastPrompt(
  424. ctx.client.session.prompt,
  425. );
  426. const requiredInterviewId = requireInterviewId(interviewId);
  427. // Add agent response with questions
  428. messagesData.push({
  429. info: { role: 'assistant' },
  430. parts: [
  431. {
  432. type: 'text',
  433. 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>',
  434. },
  435. ],
  436. });
  437. // First submission with invalid answer (wrong question ID)
  438. await expect(
  439. service.submitAnswers(requiredInterviewId, [
  440. { questionId: 'invalid-id', answer: 'Web' },
  441. ]),
  442. ).rejects.toThrow('Answers do not match the current interview questions');
  443. // Second submission with correct answer should succeed (lock was released)
  444. await expect(
  445. service.submitAnswers(requiredInterviewId, [
  446. { questionId: 'q-1', answer: 'Web' },
  447. ]),
  448. ).resolves.toBeUndefined();
  449. // Cleanup
  450. await fs.rm(tempDir, { recursive: true, force: true });
  451. });
  452. test('busy lock released when no active questions validation fails', async () => {
  453. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  454. // Start with empty messages (no questions)
  455. const messagesData: Array<{
  456. info?: { role: string };
  457. parts?: Array<{ type: string; text?: string }>;
  458. }> = [
  459. {
  460. info: { role: 'assistant' },
  461. parts: [
  462. {
  463. type: 'text',
  464. text: 'Waiting.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  465. },
  466. ],
  467. },
  468. ];
  469. const ctx = createMockContext({
  470. directory: tempDir,
  471. messagesData,
  472. });
  473. const service = createInterviewService(ctx);
  474. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  475. // Create interview (baseMessageCount will be 1)
  476. const output = { parts: [] as Array<{ type: string; text?: string }> };
  477. await service.handleCommandExecuteBefore(
  478. {
  479. command: 'interview',
  480. sessionID: 'session-no-questions',
  481. arguments: 'No Questions Test',
  482. },
  483. output,
  484. );
  485. const interviewId = extractInterviewIdFromLastPrompt(
  486. ctx.client.session.prompt,
  487. );
  488. const requiredInterviewId = requireInterviewId(interviewId);
  489. // Submission with no active questions should fail and release lock
  490. await expect(
  491. service.submitAnswers(requiredInterviewId, [
  492. { questionId: 'q-1', answer: 'Web' },
  493. ]),
  494. ).rejects.toThrow('There are no active interview questions to answer');
  495. // Verify state is not busy after the failed submission
  496. const state = await service.getInterviewState(requiredInterviewId);
  497. expect(state.isBusy).toBe(false);
  498. // Cleanup
  499. await fs.rm(tempDir, { recursive: true, force: true });
  500. });
  501. });
  502. describe('session interview lifecycle', () => {
  503. test('starting /interview with different idea creates fresh interview', async () => {
  504. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  505. const ctx = createMockContext({ directory: tempDir });
  506. const service = createInterviewService(ctx);
  507. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  508. const sessionID = 'session-reuse-test';
  509. // First interview with "Idea One"
  510. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  511. await service.handleCommandExecuteBefore(
  512. { command: 'interview', sessionID, arguments: 'Idea One' },
  513. output1,
  514. );
  515. const interviewId1 = extractInterviewIdFromLastPrompt(
  516. ctx.client.session.prompt,
  517. );
  518. const requiredInterviewId1 = requireInterviewId(interviewId1);
  519. // Second interview with "Idea Two" - should create fresh interview
  520. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  521. await service.handleCommandExecuteBefore(
  522. { command: 'interview', sessionID, arguments: 'Idea Two' },
  523. output2,
  524. );
  525. // Get the second interview ID (should be the last prompt call)
  526. const interviewId2 = extractInterviewIdFromLastPrompt(
  527. ctx.client.session.prompt,
  528. );
  529. const requiredInterviewId2 = requireInterviewId(interviewId2);
  530. // Should be different interview IDs
  531. expect(interviewId1).not.toBe(interviewId2);
  532. // First interview should be marked as abandoned
  533. const state1 = await service.getInterviewState(requiredInterviewId1);
  534. expect(state1.interview.status).toBe('abandoned');
  535. // Second interview should be active
  536. const state2 = await service.getInterviewState(requiredInterviewId2);
  537. expect(state2.interview.idea).toBe('Idea Two');
  538. expect(state2.interview.status).toBe('active');
  539. // Cleanup
  540. await fs.rm(tempDir, { recursive: true, force: true });
  541. });
  542. test('reusing same idea in same session returns existing interview', async () => {
  543. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  544. const ctx = createMockContext({ directory: tempDir });
  545. const service = createInterviewService(ctx);
  546. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  547. const sessionID = 'session-same-idea';
  548. // First call with "Same Idea"
  549. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  550. await service.handleCommandExecuteBefore(
  551. { command: 'interview', sessionID, arguments: 'Same Idea' },
  552. output1,
  553. );
  554. const interviewId1 = extractInterviewIdFromLastPrompt(
  555. ctx.client.session.prompt,
  556. );
  557. expect(interviewId1).not.toBeNull();
  558. // Second call with same idea - should reuse
  559. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  560. await service.handleCommandExecuteBefore(
  561. { command: 'interview', sessionID, arguments: 'Same Idea' },
  562. output2,
  563. );
  564. const interviewId2 = extractInterviewIdFromLastPrompt(
  565. ctx.client.session.prompt,
  566. );
  567. expect(interviewId2).not.toBeNull();
  568. // Should be the same interview ID
  569. expect(interviewId1).toBe(interviewId2);
  570. // Cleanup
  571. await fs.rm(tempDir, { recursive: true, force: true });
  572. });
  573. test('session.deleted event marks interview as abandoned', async () => {
  574. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  575. const ctx = createMockContext({ directory: tempDir });
  576. const service = createInterviewService(ctx);
  577. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  578. const sessionID = 'session-delete-test';
  579. // Create interview
  580. const output = { parts: [] as Array<{ type: string; text?: string }> };
  581. await service.handleCommandExecuteBefore(
  582. { command: 'interview', sessionID, arguments: 'Delete Test' },
  583. output,
  584. );
  585. const interviewId = extractInterviewIdFromLastPrompt(
  586. ctx.client.session.prompt,
  587. );
  588. const requiredInterviewId = requireInterviewId(interviewId);
  589. // Verify interview is active
  590. const stateBefore = await service.getInterviewState(requiredInterviewId);
  591. expect(stateBefore.interview.status).toBe('active');
  592. // Simulate session deletion
  593. await service.handleEvent({
  594. event: {
  595. type: 'session.deleted',
  596. properties: { sessionID },
  597. },
  598. });
  599. // Interview should now be abandoned
  600. const stateAfter = await service.getInterviewState(requiredInterviewId);
  601. expect(stateAfter.interview.status).toBe('abandoned');
  602. // Cleanup
  603. await fs.rm(tempDir, { recursive: true, force: true });
  604. });
  605. });
  606. describe('session status handling', () => {
  607. test('session.status busy marks interview as awaiting-agent', async () => {
  608. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  609. // Start with no questions (awaiting-agent state)
  610. const messagesData: Array<{
  611. info?: { role: string };
  612. parts?: Array<{ type: string; text?: string }>;
  613. }> = [
  614. {
  615. info: { role: 'assistant' },
  616. parts: [
  617. {
  618. type: 'text',
  619. text: 'Waiting for response.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  620. },
  621. ],
  622. },
  623. ];
  624. const ctx = createMockContext({
  625. directory: tempDir,
  626. messagesData,
  627. });
  628. const service = createInterviewService(ctx);
  629. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  630. const sessionID = 'session-busy-test';
  631. // Create interview
  632. const output = { parts: [] as Array<{ type: string; text?: string }> };
  633. await service.handleCommandExecuteBefore(
  634. { command: 'interview', sessionID, arguments: 'Busy Test' },
  635. output,
  636. );
  637. const interviewId = extractInterviewIdFromLastPrompt(
  638. ctx.client.session.prompt,
  639. );
  640. const requiredInterviewId = requireInterviewId(interviewId);
  641. // Initially should be awaiting-agent (no questions)
  642. const stateBefore = await service.getInterviewState(requiredInterviewId);
  643. expect(stateBefore.mode).toBe('awaiting-agent');
  644. // Simulate busy status
  645. await service.handleEvent({
  646. event: {
  647. type: 'session.status',
  648. properties: { sessionID, status: { type: 'busy' } },
  649. },
  650. });
  651. // Should still be awaiting-agent and marked busy
  652. const stateAfter = await service.getInterviewState(requiredInterviewId);
  653. expect(stateAfter.mode).toBe('awaiting-agent');
  654. expect(stateAfter.isBusy).toBe(true);
  655. // Cleanup
  656. await fs.rm(tempDir, { recursive: true, force: true });
  657. });
  658. });
  659. describe('configurable output folder', () => {
  660. test('creates interview in configured output folder', async () => {
  661. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  662. const ctx = createMockContext({ directory: tempDir });
  663. // Create service with custom output folder config
  664. const service = createInterviewService(ctx, {
  665. maxQuestions: 2,
  666. outputFolder: 'custom-interviews',
  667. autoOpenBrowser: true,
  668. });
  669. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  670. const output = { parts: [] as Array<{ type: string; text?: string }> };
  671. await service.handleCommandExecuteBefore(
  672. {
  673. command: 'interview',
  674. sessionID: 'session-custom-folder',
  675. arguments: 'Custom Folder Idea',
  676. },
  677. output,
  678. );
  679. // Check that file was created in custom folder
  680. const customDir = path.join(tempDir, 'custom-interviews');
  681. const files = await fs.readdir(customDir);
  682. expect(files.length).toBe(1);
  683. expect(files[0]).toBe('custom-folder-idea.md');
  684. // Verify the markdownPath in state points to custom folder
  685. const interviewId = extractInterviewIdFromLastPrompt(
  686. ctx.client.session.prompt,
  687. );
  688. const requiredInterviewId = requireInterviewId(interviewId);
  689. const state = await service.getInterviewState(requiredInterviewId);
  690. expect(state.markdownPath).toContain('custom-interviews');
  691. // Cleanup
  692. await fs.rm(tempDir, { recursive: true, force: true });
  693. });
  694. test('handles nested output folder paths', async () => {
  695. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  696. const ctx = createMockContext({ directory: tempDir });
  697. // Create service with nested output folder path
  698. const service = createInterviewService(ctx, {
  699. maxQuestions: 2,
  700. outputFolder: 'docs/interviews',
  701. autoOpenBrowser: true,
  702. });
  703. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  704. const output = { parts: [] as Array<{ type: string; text?: string }> };
  705. await service.handleCommandExecuteBefore(
  706. {
  707. command: 'interview',
  708. sessionID: 'session-nested',
  709. arguments: 'Nested Path Idea',
  710. },
  711. output,
  712. );
  713. // Check that file was created in nested folder
  714. const nestedDir = path.join(tempDir, 'docs', 'interviews');
  715. const files = await fs.readdir(nestedDir);
  716. expect(files.length).toBe(1);
  717. expect(files[0]).toBe('nested-path-idea.md');
  718. // Cleanup
  719. await fs.rm(tempDir, { recursive: true, force: true });
  720. });
  721. });
  722. describe('resuming with existing markdown file', () => {
  723. test('resumes existing file and sends resume prompt instead of kickoff', async () => {
  724. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  725. const ctx = createMockContext({ directory: tempDir });
  726. // Pre-create an existing interview file
  727. const interviewDir = path.join(tempDir, 'interview');
  728. await fs.mkdir(interviewDir, { recursive: true });
  729. const existingFilePath = path.join(interviewDir, 'existing-idea.md');
  730. await fs.writeFile(
  731. existingFilePath,
  732. '# Existing Idea\n\n## Current spec\n\nExisting spec content.\n\n## Q&A history\n\nQ: What platform?\nA: Web\n',
  733. 'utf8',
  734. );
  735. const service = createInterviewService(ctx);
  736. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  737. const output = { parts: [] as Array<{ type: string; text?: string }> };
  738. // Resume by referencing the existing file basename
  739. await service.handleCommandExecuteBefore(
  740. {
  741. command: 'interview',
  742. sessionID: 'session-resume',
  743. arguments: 'existing-idea',
  744. },
  745. output,
  746. );
  747. // Should send resume prompt (references existing document)
  748. const outputText = extractOutputText(output);
  749. expect(outputText).toContain('Resume the interview');
  750. expect(outputText).toContain('Existing Idea');
  751. expect(outputText).toContain('Existing spec content');
  752. // Should NOT send kickoff prompt
  753. expect(outputText).not.toContain(
  754. 'You are running an interview q&a session',
  755. );
  756. expect(outputText).not.toContain('Initial idea:');
  757. // Cleanup
  758. await fs.rm(tempDir, { recursive: true, force: true });
  759. });
  760. test('resumes by full relative path to existing file', async () => {
  761. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  762. const ctx = createMockContext({ directory: tempDir });
  763. // Pre-create an existing interview file in custom location
  764. const customDir = path.join(tempDir, 'docs');
  765. await fs.mkdir(customDir, { recursive: true });
  766. const existingFilePath = path.join(customDir, 'my-project.md');
  767. await fs.writeFile(
  768. existingFilePath,
  769. '# My Project\n\n## Current spec\n\nProject spec here.\n\n## Q&A history\n\nNo answers yet.\n',
  770. 'utf8',
  771. );
  772. const service = createInterviewService(ctx);
  773. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  774. const output = { parts: [] as Array<{ type: string; text?: string }> };
  775. // Resume by referencing the relative path
  776. await service.handleCommandExecuteBefore(
  777. {
  778. command: 'interview',
  779. sessionID: 'session-resume-path',
  780. arguments: 'docs/my-project.md',
  781. },
  782. output,
  783. );
  784. // Should send resume prompt
  785. const outputText = extractOutputText(output);
  786. expect(outputText).toContain('Resume the interview');
  787. expect(outputText).toContain('My Project');
  788. // Cleanup
  789. await fs.rm(tempDir, { recursive: true, force: true });
  790. });
  791. test('reuses same file when resuming multiple times', async () => {
  792. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  793. const ctx = createMockContext({ directory: tempDir });
  794. // Pre-create an existing interview file
  795. const interviewDir = path.join(tempDir, 'interview');
  796. await fs.mkdir(interviewDir, { recursive: true });
  797. const existingFilePath = path.join(interviewDir, 'reusable.md');
  798. await fs.writeFile(
  799. existingFilePath,
  800. '# Reusable Interview\n\n## Current spec\n\nOriginal content.\n\n## Q&A history\n\n',
  801. 'utf8',
  802. );
  803. const service = createInterviewService(ctx);
  804. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  805. // First resume
  806. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  807. await service.handleCommandExecuteBefore(
  808. {
  809. command: 'interview',
  810. sessionID: 'session-reuse-1',
  811. arguments: 'reusable',
  812. },
  813. output1,
  814. );
  815. const interviewId1 = extractInterviewIdFromLastPrompt(
  816. ctx.client.session.prompt,
  817. );
  818. // Second resume (different session, same file)
  819. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  820. await service.handleCommandExecuteBefore(
  821. {
  822. command: 'interview',
  823. sessionID: 'session-reuse-2',
  824. arguments: 'reusable',
  825. },
  826. output2,
  827. );
  828. const interviewId2 = extractInterviewIdFromLastPrompt(
  829. ctx.client.session.prompt,
  830. );
  831. // Both should reference the same file
  832. const state1 = await service.getInterviewState(
  833. requireInterviewId(interviewId1),
  834. );
  835. const state2 = await service.getInterviewState(
  836. requireInterviewId(interviewId2),
  837. );
  838. expect(state1.markdownPath).toBe(state2.markdownPath);
  839. expect(state1.markdownPath).toContain('reusable.md');
  840. // Cleanup
  841. await fs.rm(tempDir, { recursive: true, force: true });
  842. });
  843. });
  844. describe('configurable maxQuestions', () => {
  845. test('kickoff prompt references configured maxQuestions count', async () => {
  846. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  847. const ctx = createMockContext({ directory: tempDir });
  848. // Create service with custom maxQuestions
  849. const service = createInterviewService(ctx, {
  850. maxQuestions: 5,
  851. outputFolder: 'interview',
  852. autoOpenBrowser: true,
  853. });
  854. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  855. const output = { parts: [] as Array<{ type: string; text?: string }> };
  856. await service.handleCommandExecuteBefore(
  857. {
  858. command: 'interview',
  859. sessionID: 'session-max-q',
  860. arguments: 'Max Questions Test',
  861. },
  862. output,
  863. );
  864. // Kickoff prompt should reference the configured maxQuestions
  865. const outputText = extractOutputText(output);
  866. expect(outputText).toContain('at most 5 questions');
  867. expect(outputText).toContain('Return 0 to 5 questions');
  868. expect(outputText).toContain('Do not ask more than 5 questions');
  869. // Cleanup
  870. await fs.rm(tempDir, { recursive: true, force: true });
  871. });
  872. test('resume prompt references configured maxQuestions count', async () => {
  873. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  874. const ctx = createMockContext({ directory: tempDir });
  875. // Pre-create an existing file to trigger resume
  876. const interviewDir = path.join(tempDir, 'interview');
  877. await fs.mkdir(interviewDir, { recursive: true });
  878. await fs.writeFile(
  879. path.join(interviewDir, 'resume-max.md'),
  880. '# Resume Max\n\n## Current spec\n\nSpec.\n\n## Q&A history\n\n',
  881. 'utf8',
  882. );
  883. // Create service with custom maxQuestions
  884. const service = createInterviewService(ctx, {
  885. maxQuestions: 3,
  886. outputFolder: 'interview',
  887. autoOpenBrowser: true,
  888. });
  889. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  890. const output = { parts: [] as Array<{ type: string; text?: string }> };
  891. await service.handleCommandExecuteBefore(
  892. {
  893. command: 'interview',
  894. sessionID: 'session-resume-max',
  895. arguments: 'resume-max',
  896. },
  897. output,
  898. );
  899. // Resume prompt should reference the configured maxQuestions
  900. const outputText = extractOutputText(output);
  901. expect(outputText).toContain('up to 3 at a time');
  902. // Cleanup
  903. await fs.rm(tempDir, { recursive: true, force: true });
  904. });
  905. test('state exposes at most configured maxQuestions questions', async () => {
  906. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  907. // Start with empty messages
  908. const messagesData: Array<{
  909. info?: { role: string };
  910. parts?: Array<{ type: string; text?: string }>;
  911. }> = [];
  912. const ctx = createMockContext({
  913. directory: tempDir,
  914. messagesData,
  915. });
  916. // Create service with maxQuestions = 2
  917. const service = createInterviewService(ctx, {
  918. maxQuestions: 2,
  919. outputFolder: 'interview',
  920. autoOpenBrowser: true,
  921. });
  922. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  923. const output = { parts: [] as Array<{ type: string; text?: string }> };
  924. // Create interview first (baseMessageCount = 0)
  925. await service.handleCommandExecuteBefore(
  926. {
  927. command: 'interview',
  928. sessionID: 'session-parse-max',
  929. arguments: 'Parse Max Test',
  930. },
  931. output,
  932. );
  933. const interviewId = extractInterviewIdFromLastPrompt(
  934. ctx.client.session.prompt,
  935. );
  936. const requiredInterviewId = requireInterviewId(interviewId);
  937. // Now add the agent response with more questions than maxQuestions
  938. messagesData.push({
  939. info: { role: 'assistant' },
  940. parts: [
  941. {
  942. type: 'text',
  943. 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>',
  944. },
  945. ],
  946. });
  947. // State should only expose at most maxQuestions questions
  948. const state = await service.getInterviewState(requiredInterviewId);
  949. expect(state.questions.length).toBeLessThanOrEqual(2);
  950. expect(state.questions.length).toBe(2);
  951. // Cleanup
  952. await fs.rm(tempDir, { recursive: true, force: true });
  953. });
  954. test('answer prompt references configured maxQuestions count', async () => {
  955. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  956. // Start with empty messages
  957. const messagesData: Array<{
  958. info?: { role: string };
  959. parts?: Array<{ type: string; text?: string }>;
  960. }> = [];
  961. const ctx = createMockContext({
  962. directory: tempDir,
  963. messagesData,
  964. });
  965. // Create service with custom maxQuestions
  966. const service = createInterviewService(ctx, {
  967. maxQuestions: 4,
  968. outputFolder: 'interview',
  969. autoOpenBrowser: true,
  970. });
  971. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  972. // Create interview first (baseMessageCount = 0)
  973. const output = { parts: [] as Array<{ type: string; text?: string }> };
  974. await service.handleCommandExecuteBefore(
  975. {
  976. command: 'interview',
  977. sessionID: 'session-answer-max',
  978. arguments: 'Answer Max Test',
  979. },
  980. output,
  981. );
  982. const interviewId = extractInterviewIdFromLastPrompt(
  983. ctx.client.session.prompt,
  984. );
  985. const requiredInterviewId = requireInterviewId(interviewId);
  986. // Now add the agent response with a question
  987. messagesData.push({
  988. info: { role: 'assistant' },
  989. parts: [
  990. {
  991. type: 'text',
  992. text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  993. },
  994. ],
  995. });
  996. // Clear previous prompt calls to capture the answer prompt
  997. ctx.client.session.prompt.mock.calls.length = 0;
  998. // Submit an answer
  999. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'A' }];
  1000. await service.submitAnswers(requiredInterviewId, answers);
  1001. // Answer prompt should reference the configured maxQuestions
  1002. const lastPromptText = getPromptTexts(ctx.client.session.prompt).join(
  1003. '\n',
  1004. );
  1005. expect(lastPromptText).toContain('Return 0 to 4 questions');
  1006. // Cleanup
  1007. await fs.rm(tempDir, { recursive: true, force: true });
  1008. });
  1009. });
  1010. describe('agent-provided title', () => {
  1011. test('renames file when assistant provides title in interview_state', async () => {
  1012. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1013. // Start with empty messages
  1014. const messagesData: Array<{
  1015. info?: { role: string };
  1016. parts?: Array<{ type: string; text?: string }>;
  1017. }> = [];
  1018. const ctx = createMockContext({
  1019. directory: tempDir,
  1020. messagesData,
  1021. });
  1022. const service = createInterviewService(ctx);
  1023. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1024. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1025. // Create interview with user's idea
  1026. await service.handleCommandExecuteBefore(
  1027. {
  1028. command: 'interview',
  1029. sessionID: 'session-title-test',
  1030. arguments: 'My Great App Idea With Long Description',
  1031. },
  1032. output,
  1033. );
  1034. // Initial file should use slugified user input
  1035. const interviewDir = path.join(tempDir, 'interview');
  1036. let files = await fs.readdir(interviewDir);
  1037. expect(files.length).toBe(1);
  1038. expect(files[0]).toBe('my-great-app-idea-with-long-description.md');
  1039. const interviewId = extractInterviewIdFromLastPrompt(
  1040. ctx.client.session.prompt,
  1041. );
  1042. const requiredInterviewId = requireInterviewId(interviewId);
  1043. // Now add agent response with a concise title
  1044. messagesData.push({
  1045. info: { role: 'assistant' },
  1046. parts: [
  1047. {
  1048. type: 'text',
  1049. 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>',
  1050. },
  1051. ],
  1052. });
  1053. // Sync interview (this triggers the rename)
  1054. const state = await service.getInterviewState(requiredInterviewId);
  1055. // File should be renamed to use assistant-provided title
  1056. files = await fs.readdir(interviewDir);
  1057. expect(files.length).toBe(1);
  1058. expect(files[0]).toBe('task-manager.md');
  1059. expect(state.markdownPath).toContain('task-manager.md');
  1060. // Cleanup
  1061. await fs.rm(tempDir, { recursive: true, force: true });
  1062. });
  1063. test('keeps original filename when assistant omits title', async () => {
  1064. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1065. const messagesData: Array<{
  1066. info?: { role: string };
  1067. parts?: Array<{ type: string; text?: string }>;
  1068. }> = [];
  1069. const ctx = createMockContext({
  1070. directory: tempDir,
  1071. messagesData,
  1072. });
  1073. const service = createInterviewService(ctx);
  1074. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1075. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1076. await service.handleCommandExecuteBefore(
  1077. {
  1078. command: 'interview',
  1079. sessionID: 'session-no-title',
  1080. arguments: 'Simple Idea',
  1081. },
  1082. output,
  1083. );
  1084. const interviewDir = path.join(tempDir, 'interview');
  1085. let files = await fs.readdir(interviewDir);
  1086. expect(files[0]).toBe('simple-idea.md');
  1087. const interviewId = extractInterviewIdFromLastPrompt(
  1088. ctx.client.session.prompt,
  1089. );
  1090. const requiredInterviewId = requireInterviewId(interviewId);
  1091. // Agent response without title field
  1092. messagesData.push({
  1093. info: { role: 'assistant' },
  1094. parts: [
  1095. {
  1096. type: 'text',
  1097. 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>',
  1098. },
  1099. ],
  1100. });
  1101. const state = await service.getInterviewState(requiredInterviewId);
  1102. // Filename should remain unchanged
  1103. files = await fs.readdir(interviewDir);
  1104. expect(files[0]).toBe('simple-idea.md');
  1105. expect(state.markdownPath).toContain('simple-idea.md');
  1106. // Cleanup
  1107. await fs.rm(tempDir, { recursive: true, force: true });
  1108. });
  1109. test('does not rename if target filename already exists', async () => {
  1110. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1111. const messagesData: Array<{
  1112. info?: { role: string };
  1113. parts?: Array<{ type: string; text?: string }>;
  1114. }> = [];
  1115. const ctx = createMockContext({
  1116. directory: tempDir,
  1117. messagesData,
  1118. });
  1119. // Pre-create a file with the target name
  1120. const interviewDir = path.join(tempDir, 'interview');
  1121. await fs.mkdir(interviewDir, { recursive: true });
  1122. await fs.writeFile(
  1123. path.join(interviewDir, 'target-name.md'),
  1124. '# Existing\n\n## Current spec\n\nExisting.\n\n## Q&A history\n\n',
  1125. 'utf8',
  1126. );
  1127. const service = createInterviewService(ctx);
  1128. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1129. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1130. await service.handleCommandExecuteBefore(
  1131. {
  1132. command: 'interview',
  1133. sessionID: 'session-existing',
  1134. arguments: 'Original Idea',
  1135. },
  1136. output,
  1137. );
  1138. let files = await fs.readdir(interviewDir);
  1139. expect(files).toContain('original-idea.md');
  1140. expect(files).toContain('target-name.md');
  1141. const interviewId = extractInterviewIdFromLastPrompt(
  1142. ctx.client.session.prompt,
  1143. );
  1144. const requiredInterviewId = requireInterviewId(interviewId);
  1145. // Agent suggests a title that matches existing file
  1146. messagesData.push({
  1147. info: { role: 'assistant' },
  1148. parts: [
  1149. {
  1150. type: 'text',
  1151. 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>',
  1152. },
  1153. ],
  1154. });
  1155. const state = await service.getInterviewState(requiredInterviewId);
  1156. // Should not rename (would overwrite existing file)
  1157. files = await fs.readdir(interviewDir);
  1158. expect(files).toContain('original-idea.md');
  1159. expect(files).toContain('target-name.md');
  1160. expect(state.markdownPath).toContain('original-idea.md');
  1161. // Cleanup
  1162. await fs.rm(tempDir, { recursive: true, force: true });
  1163. });
  1164. });
  1165. describe('autoOpenBrowser config', () => {
  1166. test('uses injected browser opener instead of opening a real browser in tests', async () => {
  1167. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1168. const ctx = createMockContext({ directory: tempDir });
  1169. const { service, openBrowserMock } = createTestService(ctx, {
  1170. maxQuestions: 2,
  1171. outputFolder: 'interview',
  1172. autoOpenBrowser: true,
  1173. });
  1174. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1175. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1176. await service.handleCommandExecuteBefore(
  1177. {
  1178. command: 'interview',
  1179. sessionID: 'session-browser-open',
  1180. arguments: 'Browser Open Test',
  1181. },
  1182. output,
  1183. );
  1184. expect(openBrowserMock).toHaveBeenCalledTimes(1);
  1185. await fs.rm(tempDir, { recursive: true, force: true });
  1186. });
  1187. test('kickoff prompt includes title field guidance', async () => {
  1188. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1189. const ctx = createMockContext({ directory: tempDir });
  1190. const service = createInterviewService(ctx, {
  1191. maxQuestions: 2,
  1192. outputFolder: 'interview',
  1193. autoOpenBrowser: true,
  1194. });
  1195. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1196. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1197. await service.handleCommandExecuteBefore(
  1198. {
  1199. command: 'interview',
  1200. sessionID: 'session-browser-config',
  1201. arguments: 'Browser Config Test',
  1202. },
  1203. output,
  1204. );
  1205. // Kickoff prompt should mention title field
  1206. const outputText = extractOutputText(output);
  1207. expect(outputText).toContain('"title":');
  1208. expect(outputText).toContain('concise-kebab-case-title-for-filename');
  1209. // Cleanup
  1210. await fs.rm(tempDir, { recursive: true, force: true });
  1211. });
  1212. });
  1213. });
  1214. describe('renderInterviewPage', () => {
  1215. test('escapes HTML special characters in interviewId for title', () => {
  1216. const maliciousId = '<script>alert("xss")</script>';
  1217. const html = renderInterviewPage(maliciousId);
  1218. // Should not contain raw script tags in title
  1219. expect(html).not.toContain('<title>Interview <script>');
  1220. // Should contain escaped version in title
  1221. expect(html).toContain(
  1222. '<title>Interview &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</title>',
  1223. );
  1224. });
  1225. test('escapes ampersand in interviewId', () => {
  1226. const idWithAmpersand = 'A&B Test';
  1227. const html = renderInterviewPage(idWithAmpersand);
  1228. expect(html).toContain('<title>Interview A&amp;B Test</title>');
  1229. expect(html).not.toContain('<title>Interview A&B Test</title>');
  1230. });
  1231. test('escapes single quotes in interviewId', () => {
  1232. const idWithQuote = "test'quote";
  1233. const html = renderInterviewPage(idWithQuote);
  1234. expect(html).toContain('<title>Interview test&#39;quote</title>');
  1235. });
  1236. test('preserves safe interviewId characters', () => {
  1237. const safeId = 'my-interview-123_test';
  1238. const html = renderInterviewPage(safeId);
  1239. expect(html).toContain(`<title>Interview ${safeId}</title>`);
  1240. });
  1241. test('interviewId in JSON script tag is properly stringified', () => {
  1242. const idWithQuotes = 'test"onclick"evil';
  1243. const html = renderInterviewPage(idWithQuotes);
  1244. // The interviewId in the JavaScript should be JSON.stringify'd
  1245. // JSON.stringify escapes quotes as \"
  1246. expect(html).toContain('const interviewId = ');
  1247. // The actual output has escaped quotes for JavaScript string
  1248. expect(html).toContain('"test\\"onclick\\"evil"');
  1249. });
  1250. test('does not inject raw interviewId into HTML title', () => {
  1251. const xssAttempt = '<img src=x onerror=alert(1)>';
  1252. const html = renderInterviewPage(xssAttempt);
  1253. // Title should be escaped
  1254. expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
  1255. expect(html).toContain(
  1256. '<title>Interview &lt;img src=x onerror=alert(1)&gt;</title>',
  1257. );
  1258. });
  1259. test('renders a self-contained brand mark', () => {
  1260. const html = renderInterviewPage('brand-test');
  1261. expect(html).toContain('<svg');
  1262. expect(html).not.toContain('https://ohmyopencodeslim.com');
  1263. });
  1264. });