| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132 |
- import { describe, expect, mock, test } from 'bun:test';
- import * as fs from 'node:fs/promises';
- import { createServer } from 'node:http';
- import * as path from 'node:path';
- import { InterviewConfigSchema } from '../config/schema';
- import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils';
- import { createInterviewServer } from './server';
- import {
- createInterviewService as createRealInterviewService,
- MAX_RETAINED_ABANDONED,
- } from './service';
- import { bindFreePort } from './test-port';
- import type { InterviewAnswer } from './types';
- import { renderInterviewPage } from './ui';
- // Intercept getClient calls so service code uses the same session mocks
- // that test assertions inspect.
- mock.module('../utils/opencode-client', () => ({
- getClient: (ctx: any) => ({
- session: ctx._sessionMock ?? ctx.client.session,
- }),
- }));
- // Mock the plugin context with mutable message array
- function createMockContext(overrides?: {
- directory?: string;
- messagesData?: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }>;
- promptImpl?: (args: any) => Promise<unknown>;
- }) {
- // Use a mutable array that can be updated after creation
- const messagesData = overrides?.messagesData ?? [];
- const sessionMock = {
- messages: mock(async () => ({ data: messagesData })),
- prompt: mock(async (args: any) => {
- if (overrides?.promptImpl) {
- return await overrides.promptImpl(args);
- }
- return {};
- }),
- promptAsync: mock(async (args: any) => {
- if (overrides?.promptImpl) {
- return await overrides.promptImpl(args);
- }
- return {};
- }),
- update: mock(async () => ({})),
- };
- return {
- client: {
- session: sessionMock,
- },
- directory: overrides?.directory ?? '/test/directory',
- _sessionMock: sessionMock,
- } as any;
- }
- // Helper to extract text from current v1 prompt calls (parts is in body)
- function getPromptTexts(promptMock: {
- mock: {
- calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]>;
- };
- }): string[] {
- return promptMock.mock.calls
- .map((call) => call[0].body?.parts?.[0]?.text ?? '')
- .filter(Boolean);
- }
- // Helper to extract interview ID from the last prompt call
- function extractInterviewIdFromLastPrompt(promptMock: {
- mock: {
- calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]>;
- };
- }): string | null {
- const calls = promptMock.mock.calls;
- if (calls.length === 0) return null;
- // Get the last call
- const lastCall = calls[calls.length - 1];
- const text = lastCall[0].body?.parts?.[0]?.text ?? '';
- const match = text.match(/interview\/([^\s]+)/);
- return match ? match[1] : null;
- }
- // Helper to extract text from output parts (kickoff/resume prompts go here)
- function extractOutputText(output: {
- parts: Array<{ type: string; text?: string }>;
- }): string {
- const textPart = output.parts.find((part) => part.type === 'text');
- return textPart?.text ?? '';
- }
- function requireInterviewId(value: string | null): string {
- expect(value).not.toBeNull();
- return value as string;
- }
- function createInterviewService(
- ctx: ReturnType<typeof createMockContext>,
- config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
- deps?: Parameters<typeof createRealInterviewService>[2],
- ) {
- const resolvedConfig = config
- ? InterviewConfigSchema.parse(config)
- : undefined;
- return createRealInterviewService(ctx, resolvedConfig, {
- openBrowser: mock((_url: string) => {}),
- ...deps,
- });
- }
- function createTestService(
- ctx: ReturnType<typeof createMockContext>,
- config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
- deps?: Parameters<typeof createRealInterviewService>[2],
- ) {
- const openBrowserMock = mock((_url: string) => {});
- const resolvedConfig = config
- ? InterviewConfigSchema.parse(config)
- : undefined;
- const service = createRealInterviewService(ctx, resolvedConfig, {
- openBrowser: openBrowserMock,
- ...deps,
- });
- return {
- service,
- openBrowserMock,
- };
- }
- function createRuntimeEnv(
- overrides: NodeJS.ProcessEnv = {},
- ): NodeJS.ProcessEnv {
- return { ...overrides };
- }
- describe('interview service', () => {
- describe('/interview <idea> command', () => {
- test('creates interview and sends kickoff prompt with UI notification', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- // Set up base URL resolver to avoid server error
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = {
- parts: [] as Array<{
- type: string;
- text?: string;
- synthetic?: boolean;
- metadata?: Record<string, unknown>;
- }>,
- };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-123',
- arguments: 'My App Idea',
- },
- output,
- );
- // Should inject kickoff prompt into output
- expect(output.parts.length).toBe(1);
- expect(output.parts[0].type).toBe('text');
- expect(output.parts[0].text).toContain('My App Idea');
- expect(output.parts[0].text).toContain('<interview_state>');
- expect(output.parts[0]).toMatchObject({
- synthetic: true,
- metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
- });
- // Should send UI notification prompt to session
- expect(ctx.client.session.prompt).toHaveBeenCalled();
- const promptTexts = getPromptTexts(ctx.client.session.prompt);
- expect(
- promptTexts.some((text) => text.includes('Interview UI ready')),
- ).toBe(true);
- expect(promptTexts.some((text) => text.includes('/interview/'))).toBe(
- true,
- );
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('renames session with interview title on creation', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-rename',
- arguments: 'build a task manager',
- },
- output,
- );
- expect(ctx.client.session.update).toHaveBeenCalledTimes(1);
- expect(ctx.client.session.update.mock.calls[0][0]).toEqual({
- path: { id: 'session-rename' },
- body: { title: 'Interview: build a task manager' },
- });
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('truncates session title to 50 chars with ellipsis', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- const longIdea = 'a'.repeat(60);
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-truncate',
- arguments: longIdea,
- },
- output,
- );
- const title = ctx.client.session.update.mock.calls[0][0].body.title;
- expect(title.length).toBe(50);
- expect(title.endsWith('…')).toBe(true);
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('creates markdown file with readable unique filename', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-456',
- arguments: 'Test Idea',
- },
- output,
- );
- // Check that interview directory and file were created
- const interviewDir = path.join(tempDir, 'interview');
- const files = await fs.readdir(interviewDir);
- expect(files.length).toBe(1);
- // The readable slug is followed by a UUID so concurrent interviews
- // with the same idea cannot share a document.
- expect(files[0]).toMatch(
- /^test-idea-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.md$/,
- );
- // Check file content structure
- const content = await fs.readFile(
- path.join(interviewDir, files[0]),
- 'utf8',
- );
- expect(content).toContain('# Test Idea');
- expect(content).toContain('## Current spec');
- expect(content).toContain('## Q&A history');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('answer submission', () => {
- test('appends only Q/A history to markdown document', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages, then add questions after interview creation
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview first (with empty messages, so baseMessageCount = 0)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-789',
- arguments: 'Platform App',
- },
- output,
- );
- // Get the interview ID from the prompt calls
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Now add the questions to messages (simulating agent response)
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // Submit an answer
- const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'Web' }];
- await service.submitAnswers(requiredInterviewId, answers);
- // Read the markdown file
- const interviewDir = path.join(tempDir, 'interview');
- const files = await fs.readdir(interviewDir);
- const content = await fs.readFile(
- path.join(interviewDir, files[0]),
- 'utf8',
- );
- // Verify Q/A was appended to history section
- expect(content).toContain('## Q&A history');
- expect(content).toContain('Q: What platform?');
- expect(content).toContain('A: Web');
- // Verify the Current spec section exists (even if empty after submission)
- expect(content).toContain('## Current spec');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('preserves existing history when appending new answers', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with messages that include one answered question and one pending
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [
- // First question and answer
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- },
- { info: { role: 'user' }, parts: [{ type: 'text', text: 'App1' }] },
- // Second question (current)
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- },
- ];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview (baseMessageCount will be 3)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-abc',
- arguments: 'Multi Round App',
- },
- output,
- );
- // Get interview ID from prompt calls
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Add a new message simulating agent response after interview creation
- // This ensures baseMessageCount (3) < current messages length
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // Submit second answer (q-2 is the active question now)
- await service.submitAnswers(requiredInterviewId, [
- { questionId: 'q-2', answer: 'Blue' },
- ]);
- // Read file after submission
- const interviewDir = path.join(tempDir, 'interview');
- const files = await fs.readdir(interviewDir);
- const content = await fs.readFile(
- path.join(interviewDir, files[0]),
- 'utf8',
- );
- // Verify Q/A is in history
- expect(content).toContain('Q: What color?');
- expect(content).toContain('A: Blue');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('replaces placeholder history on first answer submission', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-placeholder',
- arguments: 'Placeholder Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- await service.submitAnswers(requiredInterviewId, [
- { questionId: 'q-1', answer: 'Web' },
- ]);
- const interviewDir = path.join(tempDir, 'interview');
- const files = await fs.readdir(interviewDir);
- const content = await fs.readFile(
- path.join(interviewDir, files[0]),
- 'utf8',
- );
- expect(content).not.toContain('## Q&A history\n\nNo answers yet.\n\nQ:');
- expect(content).toContain('## Q&A history\n\nQ: What platform?\nA: Web');
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('rejects concurrent submission when first request holds busy lock', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- // Create a prompt that delays to hold the lock
- let promptStarted = false;
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- promptImpl: async () => {
- promptStarted = true;
- // Delay to hold the lock during test
- await new Promise((resolve) => setTimeout(resolve, 200));
- return {};
- },
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview first (baseMessageCount = 0)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-concurrent',
- arguments: 'Concurrent Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Now add the agent response with questions
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // Start first submission (will hold lock due to slow prompt)
- const firstSubmissionPromise = service.submitAnswers(
- requiredInterviewId,
- [{ questionId: 'q-1', answer: 'Web' }],
- );
- // Wait for prompt to start (indicates lock is acquired)
- while (!promptStarted) {
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
- // Second submission should be rejected immediately (busy lock held)
- await expect(
- service.submitAnswers(requiredInterviewId, [
- { questionId: 'q-1', answer: 'Mobile' },
- ]),
- ).rejects.toThrow('Interview session is busy');
- // Wait for first submission to complete (it will succeed after 200ms delay)
- await firstSubmissionPromise;
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('busy lock released when validation fails after lock acquired', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview first (baseMessageCount = 0)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-retry',
- arguments: 'Retry Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Add agent response with questions
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // First submission with invalid answer (wrong question ID)
- await expect(
- service.submitAnswers(requiredInterviewId, [
- { questionId: 'invalid-id', answer: 'Web' },
- ]),
- ).rejects.toThrow('Answers do not match the current interview questions');
- // Second submission with correct answer should succeed (lock was released)
- await expect(
- service.submitAnswers(requiredInterviewId, [
- { questionId: 'q-1', answer: 'Web' },
- ]),
- ).resolves.toBeUndefined();
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('busy lock released when no active questions validation fails', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages (no questions)
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: 'Waiting.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
- },
- ],
- },
- ];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview (baseMessageCount will be 1)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-no-questions',
- arguments: 'No Questions Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Submission with no active questions should fail and release lock
- await expect(
- service.submitAnswers(requiredInterviewId, [
- { questionId: 'q-1', answer: 'Web' },
- ]),
- ).rejects.toThrow('There are no active interview questions to answer');
- // Verify state is not busy after the failed submission
- const state = await service.getInterviewState(requiredInterviewId);
- expect(state.isBusy).toBe(false);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('session interview lifecycle', () => {
- test('starting /interview with different idea creates fresh interview', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-reuse-test';
- // First interview with "Idea One"
- const output1 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Idea One' },
- output1,
- );
- const interviewId1 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId1 = requireInterviewId(interviewId1);
- // Second interview with "Idea Two" - should create fresh interview
- const output2 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Idea Two' },
- output2,
- );
- // Get the second interview ID (should be the last prompt call)
- const interviewId2 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId2 = requireInterviewId(interviewId2);
- // Should be different interview IDs
- expect(interviewId1).not.toBe(interviewId2);
- // First interview should be marked as abandoned
- const state1 = await service.getInterviewState(requiredInterviewId1);
- expect(state1.interview.status).toBe('abandoned');
- // Second interview should be active
- const state2 = await service.getInterviewState(requiredInterviewId2);
- expect(state2.interview.idea).toBe('Idea Two');
- expect(state2.interview.status).toBe('active');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('/interview with no idea and no active interview asks for idea', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID: 'fresh-session', arguments: '' },
- output,
- );
- expect(output.parts).toHaveLength(1);
- expect(output.parts[0].text).toContain('Ask them for the product idea');
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('reusing same idea in same session returns existing interview', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-same-idea';
- // First call with "Same Idea"
- const output1 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Same Idea' },
- output1,
- );
- const interviewId1 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- expect(interviewId1).not.toBeNull();
- // Second call with same idea - should reuse
- const output2 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Same Idea' },
- output2,
- );
- const interviewId2 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- expect(interviewId2).not.toBeNull();
- // Should be the same interview ID
- expect(interviewId1).toBe(interviewId2);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('session.deleted event marks interview as abandoned', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-delete-test';
- // Create interview
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Delete Test' },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Verify interview is active
- const stateBefore = await service.getInterviewState(requiredInterviewId);
- expect(stateBefore.interview.status).toBe('active');
- // Simulate session deletion
- await service.handleEvent({
- event: {
- type: 'session.deleted',
- properties: { sessionID },
- },
- });
- // Interview should now be abandoned
- const stateAfter = await service.getInterviewState(requiredInterviewId);
- expect(stateAfter.interview.status).toBe('abandoned');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('session status handling', () => {
- test('session.status busy marks interview as awaiting-agent', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with no questions (awaiting-agent state)
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: 'Waiting for response.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
- },
- ],
- },
- ];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-busy-test';
- // Create interview
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Busy Test' },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Initially should be awaiting-agent (no questions)
- const stateBefore = await service.getInterviewState(requiredInterviewId);
- expect(stateBefore.mode).toBe('awaiting-agent');
- // Simulate busy status
- await service.handleEvent({
- event: {
- type: 'session.status',
- properties: { sessionID, status: { type: 'busy' } },
- },
- });
- // Should still be awaiting-agent and marked busy
- const stateAfter = await service.getInterviewState(requiredInterviewId);
- expect(stateAfter.mode).toBe('awaiting-agent');
- expect(stateAfter.isBusy).toBe(true);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('message.updated stores current session model for interview follow-ups', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-model-track';
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- { command: 'interview', sessionID, arguments: 'Model Track Test' },
- output,
- );
- const interviewId = requireInterviewId(
- extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
- );
- await service.handleEvent({
- event: {
- type: 'message.updated',
- properties: {
- info: {
- sessionID,
- providerID: 'openai',
- modelID: 'gpt-5.6-luna',
- },
- },
- },
- });
- ctx.client.session.promptAsync.mock.calls.length = 0;
- await service.handleNudgeAction(interviewId, 'more-questions');
- const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
- expect(call.body.model).toEqual({
- providerID: 'openai',
- modelID: 'gpt-5.6-luna',
- });
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('configurable output folder', () => {
- test('creates interview in configured output folder', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Create service with custom output folder config
- const service = createInterviewService(ctx, {
- maxQuestions: 2,
- outputFolder: 'custom-interviews',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-custom-folder',
- arguments: 'Custom Folder Idea',
- },
- output,
- );
- // Check that file was created in custom folder
- const customDir = path.join(tempDir, 'custom-interviews');
- const files = await fs.readdir(customDir);
- expect(files.length).toBe(1);
- expect(files[0]).toMatch(/^custom-folder-idea-[0-9a-f-]+\.md$/);
- // Verify the markdownPath in state points to custom folder
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- const state = await service.getInterviewState(requiredInterviewId);
- expect(state.markdownPath).toContain('custom-interviews');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('handles nested output folder paths', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Create service with nested output folder path
- const service = createInterviewService(ctx, {
- maxQuestions: 2,
- outputFolder: 'docs/interviews',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-nested',
- arguments: 'Nested Path Idea',
- },
- output,
- );
- // Check that file was created in nested folder
- const nestedDir = path.join(tempDir, 'docs', 'interviews');
- const files = await fs.readdir(nestedDir);
- expect(files.length).toBe(1);
- expect(files[0]).toMatch(/^nested-path-idea-[0-9a-f-]+\.md$/);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('resuming with existing markdown file', () => {
- test('resumes existing file and sends resume prompt instead of kickoff', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Pre-create an existing interview file
- const interviewDir = path.join(tempDir, 'interview');
- await fs.mkdir(interviewDir, { recursive: true });
- const existingFilePath = path.join(interviewDir, 'existing-idea.md');
- await fs.writeFile(
- existingFilePath,
- '# Existing Idea\n\n## Current spec\n\nExisting spec content.\n\n## Q&A history\n\nQ: What platform?\nA: Web\n',
- 'utf8',
- );
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- // Resume by referencing the existing file basename
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-resume',
- arguments: 'existing-idea',
- },
- output,
- );
- // Should send resume prompt (references existing document)
- const outputText = extractOutputText(output);
- expect(outputText).toContain('Resume the interview');
- expect(outputText).toContain('Existing Idea');
- expect(outputText).toContain('Existing spec content');
- // Should NOT send kickoff prompt
- expect(outputText).not.toContain(
- 'You are running an interview q&a session',
- );
- expect(outputText).not.toContain('Initial idea:');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('resumes by full relative path to existing file', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Pre-create an existing interview file in custom location
- const customDir = path.join(tempDir, 'docs');
- await fs.mkdir(customDir, { recursive: true });
- const existingFilePath = path.join(customDir, 'my-project.md');
- await fs.writeFile(
- existingFilePath,
- '# My Project\n\n## Current spec\n\nProject spec here.\n\n## Q&A history\n\nNo answers yet.\n',
- 'utf8',
- );
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- // Resume by referencing the relative path
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-resume-path',
- arguments: 'docs/my-project.md',
- },
- output,
- );
- // Should send resume prompt
- const outputText = extractOutputText(output);
- expect(outputText).toContain('Resume the interview');
- expect(outputText).toContain('My Project');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('reuses same file when resuming multiple times', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Pre-create an existing interview file
- const interviewDir = path.join(tempDir, 'interview');
- await fs.mkdir(interviewDir, { recursive: true });
- const existingFilePath = path.join(interviewDir, 'reusable.md');
- await fs.writeFile(
- existingFilePath,
- '# Reusable Interview\n\n## Current spec\n\nOriginal content.\n\n## Q&A history\n\n',
- 'utf8',
- );
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // First resume
- const output1 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-reuse-1',
- arguments: 'reusable',
- },
- output1,
- );
- const interviewId1 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- // Second resume (different session, same file)
- const output2 = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-reuse-2',
- arguments: 'reusable',
- },
- output2,
- );
- const interviewId2 = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- // Both should reference the same file
- const state1 = await service.getInterviewState(
- requireInterviewId(interviewId1),
- );
- const state2 = await service.getInterviewState(
- requireInterviewId(interviewId2),
- );
- expect(state1.markdownPath).toBe(state2.markdownPath);
- expect(state1.markdownPath).toContain('reusable.md');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('configurable maxQuestions', () => {
- test('kickoff prompt references configured maxQuestions count', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Create service with custom maxQuestions
- const service = createInterviewService(ctx, {
- maxQuestions: 5,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-max-q',
- arguments: 'Max Questions Test',
- },
- output,
- );
- // Kickoff prompt should reference the configured maxQuestions
- const outputText = extractOutputText(output);
- expect(outputText).toContain('at most 5 questions');
- expect(outputText).toContain('Return 0 to 5 questions');
- expect(outputText).toContain('Do not ask more than 5 questions');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('resume prompt references configured maxQuestions count', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- // Pre-create an existing file to trigger resume
- const interviewDir = path.join(tempDir, 'interview');
- await fs.mkdir(interviewDir, { recursive: true });
- await fs.writeFile(
- path.join(interviewDir, 'resume-max.md'),
- '# Resume Max\n\n## Current spec\n\nSpec.\n\n## Q&A history\n\n',
- 'utf8',
- );
- // Create service with custom maxQuestions
- const service = createInterviewService(ctx, {
- maxQuestions: 3,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-resume-max',
- arguments: 'resume-max',
- },
- output,
- );
- // Resume prompt should reference the configured maxQuestions
- const outputText = extractOutputText(output);
- expect(outputText).toContain('up to 3 at a time');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('state exposes at most configured maxQuestions questions', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- // Create service with maxQuestions = 2
- const service = createInterviewService(ctx, {
- maxQuestions: 2,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- // Create interview first (baseMessageCount = 0)
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-parse-max',
- arguments: 'Parse Max Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Now add the agent response with more questions than maxQuestions
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // State should only expose at most maxQuestions questions
- const state = await service.getInterviewState(requiredInterviewId);
- expect(state.questions.length).toBeLessThanOrEqual(2);
- expect(state.questions.length).toBe(2);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('answer prompt references configured maxQuestions count', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- // Create service with custom maxQuestions
- const service = createInterviewService(ctx, {
- maxQuestions: 4,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- // Create interview first (baseMessageCount = 0)
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-answer-max',
- arguments: 'Answer Max Test',
- },
- output,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Now add the agent response with a question
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
- },
- ],
- });
- // Clear previous prompt calls to capture the answer prompt
- ctx.client.session.promptAsync.mock.calls.length = 0;
- // Submit an answer
- const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'A' }];
- await service.submitAnswers(requiredInterviewId, answers);
- // Answer prompt should reference the configured maxQuestions
- const lastPromptText = getPromptTexts(
- ctx.client.session.promptAsync,
- ).join('\n');
- expect(lastPromptText).toContain('Return 0 to 4 questions');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('reuses observed session model when submitting interview answers', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const sessionID = 'session-answer-model';
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID,
- arguments: 'Answer Model Test',
- },
- output,
- );
- const interviewId = requireInterviewId(
- extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
- );
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
- },
- ],
- });
- await service.handleEvent({
- event: {
- type: 'message.updated',
- properties: {
- info: {
- sessionID,
- providerID: 'anthropic',
- modelID: 'claude-sonnet-4-6',
- },
- },
- },
- });
- ctx.client.session.promptAsync.mock.calls.length = 0;
- await service.submitAnswers(interviewId, [
- { questionId: 'q-1', answer: 'A' },
- ]);
- const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
- expect(call.body.model).toEqual({
- providerID: 'anthropic',
- modelID: 'claude-sonnet-4-6',
- });
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('agent-provided title', () => {
- test('keeps path stable and stores assistant title in markdown', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- // Start with empty messages
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- // Create interview with user's idea
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-title-test',
- arguments: 'My Great App Idea With Long Description',
- },
- output,
- );
- // Initial file should use slugified user input
- const interviewDir = path.join(tempDir, 'interview');
- let files = await fs.readdir(interviewDir);
- expect(files.length).toBe(1);
- expect(files[0]).toMatch(
- /^my-great-app-idea-with-long-description-[0-9a-f-]+\.md$/,
- );
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Now add agent response with a concise title
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- // Sync interview (the assistant title updates Markdown, not its path)
- const state = await service.getInterviewState(requiredInterviewId);
- // The durable path remains tied to the record's original idea.
- files = await fs.readdir(interviewDir);
- expect(files.length).toBe(1);
- expect(files[0]).toMatch(
- /^my-great-app-idea-with-long-description-[0-9a-f-]+\.md$/,
- );
- expect(state.markdownPath).toMatch(
- /my-great-app-idea-with-long-description-[0-9a-f-]+\.md$/,
- );
- expect(state.document).toContain('# task-manager');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('keeps original filename when assistant omits title', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-no-title',
- arguments: 'Simple Idea',
- },
- output,
- );
- const interviewDir = path.join(tempDir, 'interview');
- let files = await fs.readdir(interviewDir);
- expect(files[0]).toMatch(/^simple-idea-[0-9a-f-]+\.md$/);
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Agent response without title field
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- const state = await service.getInterviewState(requiredInterviewId);
- // Filename should remain unchanged
- files = await fs.readdir(interviewDir);
- expect(files[0]).toMatch(/^simple-idea-[0-9a-f-]+\.md$/);
- expect(state.markdownPath).toMatch(/simple-idea-[0-9a-f-]+\.md$/);
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('keeps shared path when title matches another file', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const messagesData: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }> = [];
- const ctx = createMockContext({
- directory: tempDir,
- messagesData,
- });
- // Pre-create a file with the target name
- const interviewDir = path.join(tempDir, 'interview');
- await fs.mkdir(interviewDir, { recursive: true });
- await fs.writeFile(
- path.join(interviewDir, 'target-name.md'),
- '# Existing\n\n## Current spec\n\nExisting.\n\n## Q&A history\n\n',
- 'utf8',
- );
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-existing',
- arguments: 'Original Idea',
- },
- output,
- );
- let files = await fs.readdir(interviewDir);
- const originalPath = files.find((file) =>
- file.startsWith('original-idea-'),
- );
- expect(originalPath).toBeDefined();
- expect(files).toContain('target-name.md');
- const interviewId = extractInterviewIdFromLastPrompt(
- ctx.client.session.prompt,
- );
- const requiredInterviewId = requireInterviewId(interviewId);
- // Agent suggests a title that matches existing file
- messagesData.push({
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- 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>',
- },
- ],
- });
- const state = await service.getInterviewState(requiredInterviewId);
- // The active document keeps its path and the unrelated file remains
- // untouched.
- files = await fs.readdir(interviewDir);
- expect(files).toContain(originalPath as string);
- expect(files).toContain('target-name.md');
- expect(state.markdownPath).toMatch(/original-idea-[0-9a-f-]+\.md$/);
- expect(state.document).toContain('# target-name');
- expect(
- await fs.readFile(path.join(interviewDir, 'target-name.md'), 'utf8'),
- ).toContain('Existing.');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- describe('autoOpenBrowser config', () => {
- test('does not open a browser during automated test runtimes', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const { service, openBrowserMock } = createTestService(
- ctx,
- {
- maxQuestions: 2,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- },
- {
- env: createRuntimeEnv({
- NODE_ENV: 'test',
- CI: '0',
- }),
- },
- );
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-browser-test-env',
- arguments: 'Browser Test Env',
- },
- output,
- );
- expect(openBrowserMock).not.toHaveBeenCalled();
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('does not open a browser in CI even when auto-open is enabled', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const { service, openBrowserMock } = createTestService(
- ctx,
- {
- maxQuestions: 2,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- },
- {
- env: createRuntimeEnv({
- CI: 'true',
- }),
- },
- );
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-browser-ci-env',
- arguments: 'Browser CI Env',
- },
- output,
- );
- expect(openBrowserMock).not.toHaveBeenCalled();
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('uses injected browser opener instead of opening a real browser in tests', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const { service, openBrowserMock } = createTestService(
- ctx,
- {
- maxQuestions: 2,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- },
- {
- env: createRuntimeEnv({
- NODE_ENV: 'development',
- CI: '0',
- }),
- },
- );
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-browser-open',
- arguments: 'Browser Open Test',
- },
- output,
- );
- expect(openBrowserMock).toHaveBeenCalledTimes(1);
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- test('kickoff prompt includes title field guidance', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx, {
- maxQuestions: 2,
- outputFolder: 'interview',
- autoOpenBrowser: true,
- });
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: 'session-browser-config',
- arguments: 'Browser Config Test',
- },
- output,
- );
- // Kickoff prompt should mention title field
- const outputText = extractOutputText(output);
- expect(outputText).toContain('"title":');
- expect(outputText).toContain('concise-kebab-case-title-for-filename');
- // Cleanup
- await fs.rm(tempDir, { recursive: true, force: true });
- });
- });
- });
- describe('renderInterviewPage', () => {
- test('escapes HTML special characters in interviewId for title', () => {
- const maliciousId = '<script>alert("xss")</script>';
- const html = renderInterviewPage(maliciousId, maliciousId);
- // Should not contain raw script tags in title
- expect(html).not.toContain('<title>Interview <script>');
- // Should contain escaped version in title
- expect(html).toContain(
- '<title>Interview <script>alert("xss")</script></title>',
- );
- });
- test('escapes ampersand in interviewId', () => {
- const idWithAmpersand = 'A&B Test';
- const html = renderInterviewPage(idWithAmpersand, idWithAmpersand);
- expect(html).toContain('<title>Interview A&B Test</title>');
- expect(html).not.toContain('<title>Interview A&B Test</title>');
- });
- test('escapes single quotes in interviewId', () => {
- const idWithQuote = "test'quote";
- const html = renderInterviewPage(idWithQuote, idWithQuote);
- expect(html).toContain('<title>Interview test'quote</title>');
- });
- test('preserves safe interviewId characters', () => {
- const safeId = 'my-interview-123_test';
- const html = renderInterviewPage(safeId, safeId);
- expect(html).toContain(`<title>Interview ${safeId}</title>`);
- });
- test('interviewId in JSON script tag is properly stringified', () => {
- const idWithQuotes = 'test"onclick"evil';
- const html = renderInterviewPage(idWithQuotes, idWithQuotes);
- // The interviewId in the JavaScript should be JSON.stringify'd
- // JSON.stringify escapes quotes as \"
- expect(html).toContain('const interviewId = ');
- // The actual output has escaped quotes for JavaScript string
- expect(html).toContain('"test\\"onclick\\"evil"');
- });
- test('does not inject raw interviewId into HTML title', () => {
- const xssAttempt = '<img src=x onerror=alert(1)>';
- const html = renderInterviewPage(xssAttempt, xssAttempt);
- // Title should be escaped
- expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
- expect(html).toContain(
- '<title>Interview <img src=x onerror=alert(1)></title>',
- );
- });
- test('renders the hosted brand logo', () => {
- const html = renderInterviewPage('brand-test', 'brand-test');
- expect(html).toContain('<img class="brand-mark"');
- expect(html).toContain(
- 'https://ohmyopencodeslim.com/android-chrome-512x512.png',
- );
- });
- test('shows explicit Enter guidance for option questions', () => {
- const html = renderInterviewPage('enter-hint-test', 'enter-hint-test');
- expect(html).toContain('.question-hint {');
- expect(html).toContain('.hint-chip {');
- expect(html).toContain(
- '<kbd>Enter</kbd><span>Accept selected answer</span>',
- );
- expect(html).toContain('<kbd>1-9</kbd><span>Choose an option</span>');
- });
- });
- describe('interview server port configuration', () => {
- const noopDeps = {
- getState: mock(
- async (_id: string) =>
- ({
- interview: {
- id: 'x',
- idea: 'x',
- status: 'active',
- markdownPath: 'x',
- },
- questions: [],
- mode: 'awaiting-agent' as const,
- isBusy: false,
- }) as any,
- ),
- listInterviewFiles: mock(async () => []),
- listInterviews: mock(() => []),
- submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
- handleNudgeAction: mock(
- async (_id: string, _action: 'more-questions' | 'confirm-complete') => {},
- ),
- outputFolder: 'interview',
- };
- test('server starts on a specific port when port is non-zero', async () => {
- const held = await bindFreePort();
- const server = createInterviewServer({
- ...noopDeps,
- port: held.port,
- server: held.server,
- });
- try {
- const baseUrl = await server.ensureStarted();
- expect(baseUrl).toBe(`http://127.0.0.1:${held.port}`);
- } finally {
- server.close();
- if (held.server.listening) held.server.close();
- }
- });
- test('server starts on a random port when port is 0', async () => {
- const server = createInterviewServer({ ...noopDeps, port: 0 });
- try {
- const baseUrl = await server.ensureStarted();
- expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
- const portStr = baseUrl.split(':').pop();
- const port = Number.parseInt(portStr ?? '0', 10);
- expect(port).toBeGreaterThan(0);
- } finally {
- server.close();
- }
- });
- test('baseUrl contains the correct port number for fixed port', async () => {
- const held = await bindFreePort();
- const server = createInterviewServer({
- ...noopDeps,
- port: held.port,
- server: held.server,
- });
- try {
- const baseUrl = await server.ensureStarted();
- const portStr = baseUrl.split(':').pop();
- const port = Number.parseInt(portStr ?? '0', 10);
- expect(port).toBe(held.port);
- } finally {
- server.close();
- if (held.server.listening) held.server.close();
- }
- });
- test('baseUrl contains a valid port number for random port', async () => {
- const server = createInterviewServer({ ...noopDeps, port: 0 });
- try {
- const baseUrl = await server.ensureStarted();
- const portStr = baseUrl.split(':').pop();
- const port = Number.parseInt(portStr ?? '0', 10);
- expect(port).toBeGreaterThanOrEqual(1);
- expect(port).toBeLessThanOrEqual(65535);
- } finally {
- server.close();
- }
- });
- test('rejects with friendly error when port is already in use', async () => {
- // Occupy a port first
- const blocker = createServer();
- const occupiedPort = await new Promise<number>((resolve, reject) => {
- blocker.listen(0, '127.0.0.1', () => {
- const addr = blocker.address();
- if (!addr || typeof addr === 'string') {
- reject(new Error('Failed to bind blocker'));
- return;
- }
- resolve(addr.port);
- });
- blocker.on('error', reject);
- });
- const server = createInterviewServer({
- ...noopDeps,
- port: occupiedPort,
- });
- try {
- await expect(server.ensureStarted()).rejects.toThrow(
- `Interview server port ${occupiedPort} is already in use`,
- );
- } finally {
- server.close();
- blocker.close();
- }
- });
- });
- describe('InterviewConfigSchema port validation', () => {
- test('accepts valid port 0', () => {
- const result = InterviewConfigSchema.parse({ port: 0 });
- expect(result.port).toBe(0);
- });
- test('accepts valid port 8080', () => {
- const result = InterviewConfigSchema.parse({ port: 8080 });
- expect(result.port).toBe(8080);
- });
- test('accepts valid port 65535', () => {
- const result = InterviewConfigSchema.parse({ port: 65535 });
- expect(result.port).toBe(65535);
- });
- test('defaults port to 0 when omitted', () => {
- const result = InterviewConfigSchema.parse({});
- expect(result.port).toBe(0);
- });
- test('rejects negative port', () => {
- expect(() => InterviewConfigSchema.parse({ port: -1 })).toThrow();
- });
- test('rejects port above 65535', () => {
- expect(() => InterviewConfigSchema.parse({ port: 70000 })).toThrow();
- });
- test('rejects float port', () => {
- expect(() => InterviewConfigSchema.parse({ port: 3.5 })).toThrow();
- });
- });
- describe('interview service abandoned-record retention', () => {
- const RETENTION_CAP = MAX_RETAINED_ABANDONED;
- async function createInterviewOnSession(
- service: ReturnType<typeof createInterviewService>,
- ctx: ReturnType<typeof createMockContext>,
- index: number,
- ): Promise<string> {
- const output = { parts: [] as Array<{ type: string; text?: string }> };
- await service.handleCommandExecuteBefore(
- {
- command: 'interview',
- sessionID: `session-${index}`,
- arguments: `Idea ${index}`,
- },
- output,
- );
- return requireInterviewId(
- extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
- );
- }
- test('evicts oldest abandoned records once the retention cap is exceeded', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- try {
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const ids: string[] = [];
- for (let i = 0; i < RETENTION_CAP + 2; i++) {
- ids.push(await createInterviewOnSession(service, ctx, i));
- // Deleting the session abandons the interview, triggering pruning.
- await service.handleEvent({
- event: {
- type: 'session.deleted',
- properties: { sessionID: `session-${i}` },
- },
- });
- }
- // The two oldest abandoned records are evicted from the registry.
- await expect(service.getInterviewState(ids[0])).rejects.toThrow(
- 'Interview not found',
- );
- await expect(service.getInterviewState(ids[1])).rejects.toThrow(
- 'Interview not found',
- );
- // The most recent abandoned record is retained and still renders.
- const retained = await service.getInterviewState(ids[ids.length - 1]);
- expect(retained.mode).toBe('abandoned');
- } finally {
- await fs.rm(tempDir, { recursive: true, force: true });
- }
- });
- test('prunes by abandonment order instead of creation order', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- try {
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const oldActiveId = await createInterviewOnSession(service, ctx, 0);
- const abandonedIds: string[] = [];
- for (let i = 1; i <= RETENTION_CAP; i++) {
- const id = await createInterviewOnSession(service, ctx, i);
- abandonedIds.push(id);
- await service.handleEvent({
- event: {
- type: 'session.deleted',
- properties: { sessionID: `session-${i}` },
- },
- });
- }
- // Abandoning the old active interview after the cap is full should retain
- // that newly abandoned record and prune the earliest previously abandoned
- // record. Its older createdAt must not make it the eviction candidate.
- await service.handleEvent({
- event: {
- type: 'session.deleted',
- properties: { sessionID: 'session-0' },
- },
- });
- await expect(service.getInterviewState(abandonedIds[0])).rejects.toThrow(
- 'Interview not found',
- );
- const oldActiveState = await service.getInterviewState(oldActiveId);
- expect(oldActiveState.mode).toBe('abandoned');
- const latestPreviouslyAbandoned = await service.getInterviewState(
- abandonedIds[abandonedIds.length - 1],
- );
- expect(latestPreviouslyAbandoned.mode).toBe('abandoned');
- } finally {
- await fs.rm(tempDir, { recursive: true, force: true });
- }
- });
- test('retains abandoned records that stay within the cap', async () => {
- const tempDir = await fs.mkdtemp('/tmp/interview-test-');
- try {
- const ctx = createMockContext({ directory: tempDir });
- const service = createInterviewService(ctx);
- service.setBaseUrlResolver(async () => 'http://localhost:9999');
- const id = await createInterviewOnSession(service, ctx, 0);
- await service.handleEvent({
- event: {
- type: 'session.deleted',
- properties: { sessionID: 'session-0' },
- },
- });
- // Below the cap, the abandoned record is kept so an open tab can still
- // render its final state.
- const state = await service.getInterviewState(id);
- expect(state.mode).toBe('abandoned');
- } finally {
- await fs.rm(tempDir, { recursive: true, force: true });
- }
- });
- });
|