From 85d5aa7585b348d8cb9e7ed284ea1ab669ea6ea7 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 15 Sep 2026 10:48:41 -0400 Subject: [PATCH 1/6] fix(mock): size the report deadline to the session, not to one number MOCK_REPORT_TIMEOUT_MS was a flat 60s passed to AbortSignal.timeout, which is a total wall clock rather than a time to first byte - the reply is one non-streaming JSON body, so nothing arrives until the whole report is generated. The report is the one call whose duration scales with the session: MockReport carries a score, a justification and a full rewritten answer per question, follow-ups included, over a body that also carries the profile and context. A twelve-turn session asks for several times the generation a three-turn one does, so one number is right for neither - and the long ones hit the deadline routinely. The abort is client-side, so the backend finishes the report and charges for it anyway. Derived from the turns actually being sent rather than the session's configured length, so a session ended early is not held to a deadline for questions it never asked and one that ran long on follow-ups gets the time they cost. Bounded at both ends; End stays live for the whole of Scoring as the way out of a request that really has hung. Refs #133 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL --- src/main/api/mock-interview.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/api/mock-interview.ts b/src/main/api/mock-interview.ts index 8429647a..b026097d 100644 --- a/src/main/api/mock-interview.ts +++ b/src/main/api/mock-interview.ts @@ -18,9 +18,28 @@ import { ApiClient, ApiResponse } from './client.js'; // non-streaming JSON reply rather than the first chunk of a stream. const MOCK_QUESTION_TIMEOUT_MS = 30_000; const MOCK_TURN_TIMEOUT_MS = 15_000; -const MOCK_REPORT_TIMEOUT_MS = 60_000; const MOCK_SPEAK_TIMEOUT_MS = 20_000; +// The report is the one call whose duration scales with the session, so it is the one call a +// single number cannot cover. `MockReport` carries a score, a justification *and* a full rewritten +// answer for every question, so a twelve-turn session asks for several times the generation a +// three-turn one does - and the flat 60s this replaced was calibrated to neither. Long sessions +// hit it routinely, which is what "scoring sometimes times out" was: an abort on the client while +// the backend went on to finish the report and charge for it. +// +// Wall clock rather than a stall timer, unlike the streaming paths. The reply is one JSON body, so +// nothing arrives until the whole thing is generated and there is no progress to detect - the +// ceiling is what keeps the deadline honest, and End stays live for the whole of `Scoring` as the +// manual way out of a request that really has hung. +const MOCK_REPORT_BASE_MS = 45_000; +const MOCK_REPORT_PER_QUESTION_MS = 20_000; +const MOCK_REPORT_MAX_MS = 240_000; + +export function mockReportTimeoutMs(questionCount: number): number { + const questions = Number.isFinite(questionCount) ? Math.max(0, questionCount) : 0; + return Math.min(MOCK_REPORT_MAX_MS, MOCK_REPORT_BASE_MS + questions * MOCK_REPORT_PER_QUESTION_MS); +} + // The probe below is one unauthenticated round-trip against a route that is never going to do // any work, so it can be far tighter than the calls that generate something. const MOCK_SUPPORT_PROBE_TIMEOUT_MS = 8_000; @@ -34,8 +53,17 @@ export class MockInterviewApi extends ApiClient { return this.post('/api/mock-interview/turn', data, MOCK_TURN_TIMEOUT_MS); } + /** + * Scored off the turns actually being sent rather than the session's configured length, so a + * session that ended early is not held to a deadline for questions it never asked and one that + * ran long on follow-ups gets the time they cost. + */ async generateReport(data: GenerateMockReportRequest): Promise> { - return this.post('/api/mock-interview/report', data, MOCK_REPORT_TIMEOUT_MS); + return this.post( + '/api/mock-interview/report', + data, + mockReportTimeoutMs(data.questions.length) + ); } /** From e8970dd12b880f4fefc0a33369ce1e4216f9bfce Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 15 Sep 2026 10:48:50 -0400 Subject: [PATCH 2/6] feat(mock): let a failed report be scored again The report is the only charged call in a session whose failure was terminal. finishToScoring lands on Finished with reportError set, and from there the answers stay on screen and stay exportable but the score they were billed for could not be obtained by any route short of a second interview. Deliberately not the automatic retry generateNextQuestion has. A timeout on this call says nothing about whether the backend finished and billed the attempt that timed out, so a silent second attempt can spend twice on the candidate's behalf - and at this call's deadline it would also double the wait before they are told anything at all. Stays on Finished rather than returning to Scoring: Scoring is an active session, so it would re-arm the navigation lock and swap the report screen the candidate is looking at for the session screen, which has no question left to show. rescoring carries the in-flight state instead. Two things fall out of splitting requestReport off. A missing setup is now a failure rather than an early return - by that point the state is already Scoring, which has no control that ends it but End, so returning stranded the session on a spinner instead of holding the terminal-state invariant. And the failure is logged, the way the question path logs its own: this is the end of a session the candidate paid for, and the reason it produced nothing was the one thing neither the screen nor the log recorded. Refs #133 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL --- src/main/ipc/mock-interview.ts | 4 ++ src/main/preload.cts | 1 + src/main/services/mock-interview.service.ts | 62 ++++++++++++++++++++- src/main/types/mock-interview.ts | 8 +++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/mock-interview.ts b/src/main/ipc/mock-interview.ts index 4c47bf58..e1e2ba3d 100644 --- a/src/main/ipc/mock-interview.ts +++ b/src/main/ipc/mock-interview.ts @@ -35,6 +35,10 @@ export function registerMockInterviewHandlers(): void { mockInterviewService.answerReady(); }); + ipcMain.handle('mock-interview:retry-scoring', async () => { + await mockInterviewService.retryScoring(); + }); + ipcMain.handle('mock-interview:end-session', async () => { await mockInterviewService.endSession(); }); diff --git a/src/main/preload.cts b/src/main/preload.cts index e1238006..e5011579 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -141,6 +141,7 @@ const electronApi = { ipcRenderer.invoke('mock-interview:ingest-answer', payload), answerFinished: () => ipcRenderer.invoke('mock-interview:answer-finished'), answerReady: () => ipcRenderer.invoke('mock-interview:answer-ready'), + retryScoring: () => ipcRenderer.invoke('mock-interview:retry-scoring'), endSession: () => ipcRenderer.invoke('mock-interview:end-session'), clear: () => ipcRenderer.invoke('mock-interview:clear'), }, diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 0f1a44e7..9e77c076 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -76,6 +76,7 @@ function initialSession(): MockInterviewSessionState { liveHints: [], report: null, reportError: null, + rescoring: false, exported: false, error: null, }; @@ -760,13 +761,30 @@ class MockInterviewService { this.setState(MockInterviewState.Scoring); this.broadcast(); + await this.requestReport(seq); + } - if (!this.session.setup) return; + /** + * Ask the backend to score the session, landing on `Finished` whatever happens. + * + * Split out of `finishToScoring` because `retryScoring()` re-runs exactly this and nothing + * around it - the guards, the state change and the reset-with-a-reason above all belong to + * reaching the end of an interview, not to the request itself. + * + * A missing setup is a failure rather than an early return, which is what it used to be. By + * here the state is already `Scoring`, and `Scoring` has no control that ends it except End - + * so returning stranded the session on a spinner instead of holding the terminal-state + * invariant this file's tests pin. + */ + private async requestReport(seq: number): Promise { try { + const setup = this.session.setup; + if (!setup) throw new Error('The interview setup is no longer available.'); + const interviewConfig = appStateService.getState().interviewConfig; const request: GenerateMockReportRequest = { language: this.language, - setup: this.session.setup, + setup, profile_data: interviewConfig.profileData, context: interviewConfig.context, questions: this.session.answers.map((a) => ({ question: a.question, answer: a.answer })), @@ -781,20 +799,58 @@ class MockInterviewService { ...this.session, report: response.data, reportError: null, + rescoring: false, state: MockInterviewState.Finished, }; } catch (error) { if (seq !== this.sessionSeq) return; + // Logged for the same reason the question path logs its own failures: this is the end of a + // session the candidate has paid for, and the reason it produced nothing was the one thing + // neither the screen nor the log recorded. + const message = describeApiError(error); + console.error(`[MockInterviewService] scoring failed: ${message}`, error); this.session = { ...this.session, report: null, - reportError: error instanceof Error ? error.message : 'Failed to score the interview', + reportError: message, + rescoring: false, state: MockInterviewState.Finished, }; } this.broadcast(); } + /** + * Score the session again after a failed attempt, because the candidate asked. + * + * The report is the only charged call in a session whose failure was terminal: the answers stay + * on screen and stay exportable, but the score they were billed for could not be obtained by any + * route short of paying for a whole second interview. + * + * Deliberately a button rather than the automatic retry `generateNextQuestion` has. A timeout on + * this call says nothing about whether the backend finished and billed the attempt that timed + * out, so a silent second attempt can spend twice on the candidate's behalf - and at this call's + * deadline it would also double the wait before they are told anything at all. Asked for on a + * screen that already has their answers on it, the spend is their decision. + * + * Stays on `Finished` rather than returning to `Scoring`: `Scoring` is an active session, so it + * would re-arm the navigation lock and swap the report screen the candidate is looking at for + * the session screen, which has no question left to show. `rescoring` carries the in-flight + * state instead. + */ + async retryScoring(): Promise { + if (this.session.state !== MockInterviewState.Finished) return; + if (this.session.rescoring || !this.session.reportError) return; + // The same guard `finishToScoring` applies before the first attempt - a billed report over a + // session with no real answers scores an interview that did not happen. + if (!this.hasRealAnswers()) return; + + const seq = this.sessionSeq; + this.session = { ...this.session, rescoring: true }; + this.broadcast(); + await this.requestReport(seq); + } + /** * End the session from any point. Scores whatever was answered so far, the same as reaching * the last question naturally - the answers already given are worth a report, not a discard. diff --git a/src/main/types/mock-interview.ts b/src/main/types/mock-interview.ts index 04baafd5..e97f204e 100644 --- a/src/main/types/mock-interview.ts +++ b/src/main/types/mock-interview.ts @@ -223,6 +223,14 @@ export interface MockInterviewSessionState { report: MockReport | null; /** Set when report generation failed - the transcript is still shown and still exportable. */ reportError: string | null; + /** + * A retry of a failed report is in flight (`retryScoring`). + * + * Separate from the `Scoring` state on purpose: this runs while the session is already + * `Finished`, so that the report screen keeps the candidate's answers on screen and the + * navigation lock stays off. See `retryScoring`. + */ + rescoring: boolean; /** * True once everything this session holds has been written to a file. * From bf17f8b041efbe8836309adb821b4fc008565329 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 15 Sep 2026 10:48:57 -0400 Subject: [PATCH 3/6] feat(mock): offer Score again where the failure is reported The report screen stated the failure and stopped there. The control goes in the alert that names it rather than beside Export and Done, because it is the answer to what that alert says and nowhere else on the screen is about the score being missing. rescoring rather than the session state drives the button, so the answers stay on screen and the navigation lock stays off while the retry is in flight. Refs #133 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL --- src/renderer/hooks/use-mock-interview.ts | 6 +++- src/renderer/pages/mock-interview/index.tsx | 3 +- src/renderer/pages/mock-interview/report.tsx | 37 +++++++++++++++++--- src/renderer/types/electron-api.d.ts | 1 + src/renderer/types/mock-interview.ts | 2 ++ 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index fbe3eec6..94917e20 100644 --- a/src/renderer/hooks/use-mock-interview.ts +++ b/src/renderer/hooks/use-mock-interview.ts @@ -113,6 +113,10 @@ export function useMockInterview() { } }; + const retryScoring = async (): Promise => { + await window.electronAPI?.mockInterview.retryScoring(); + }; + const endSession = async (): Promise => { await window.electronAPI?.mockInterview.endSession(); }; @@ -129,5 +133,5 @@ export function useMockInterview() { await window.electronAPI?.mockInterview.clear(); }; - return { session, startSession, endSession, answerFinished, answerReady, clear }; + return { session, startSession, endSession, retryScoring, answerFinished, answerReady, clear }; } diff --git a/src/renderer/pages/mock-interview/index.tsx b/src/renderer/pages/mock-interview/index.tsx index cd01ff8a..07e3b790 100644 --- a/src/renderer/pages/mock-interview/index.tsx +++ b/src/renderer/pages/mock-interview/index.tsx @@ -41,7 +41,7 @@ export default function MockInterviewPage() { const navigate = useNavigate(); const location = useLocation(); const { appState } = useAppState(); - const { session, startSession, endSession, answerFinished, answerReady, clear } = + const { session, startSession, endSession, retryScoring, answerFinished, answerReady, clear } = useMockInterview(); const { exportMockReport } = useTools(); const { confirmDiscard } = useSaveHistoryGuard(); @@ -187,6 +187,7 @@ export default function MockInterviewPage() { exportMockReport(format)} + onRetryScoring={retryScoring} onPracticeAgain={async () => { if (!(await confirmDiscard('mock-again'))) return; await clear(); diff --git a/src/renderer/pages/mock-interview/report.tsx b/src/renderer/pages/mock-interview/report.tsx index ddb4d05d..ee15f416 100644 --- a/src/renderer/pages/mock-interview/report.tsx +++ b/src/renderer/pages/mock-interview/report.tsx @@ -15,6 +15,7 @@ import type { MockInterviewSessionState } from '@/types/mock-interview'; interface ReportScreenProps { session: MockInterviewSessionState; onExport: (format: 'docx' | 'md') => Promise; + onRetryScoring: () => Promise; onPracticeAgain: () => Promise; onDone: () => Promise; } @@ -26,8 +27,14 @@ function scoreVerdict(score: number): string { return 'Needs work'; } -export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: ReportScreenProps) { - const { report, reportError, answers } = session; +export function ReportScreen({ + session, + onExport, + onRetryScoring, + onPracticeAgain, + onDone, +}: ReportScreenProps) { + const { report, reportError, rescoring, answers } = session; const [saving, setSaving] = useState<'docx' | 'md' | null>(null); const [busy, setBusy] = useState<'again' | 'done' | null>(null); const headingRef = useRef(null); @@ -81,9 +88,29 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep {reportError && ( - - The overall score could not be produced ({reportError}). Your answers are still shown - below and can still be exported. + + + The overall score could not be produced ({reportError}). Your answers are still + shown below and can still be exported. + + {/* The answers are kept, so scoring can be asked for again without re-running the + interview. Not automatic - see `retryScoring` in the service for why the spend + is the candidate's to make. */} + )} diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index cfe0a6a0..c74dfbaf 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -154,6 +154,7 @@ declare global { ingestAnswer: (payload: { type: 'partial' | 'final'; text: string }) => Promise; answerFinished: () => Promise; answerReady: () => Promise; + retryScoring: () => Promise; endSession: () => Promise; clear: () => Promise; }; diff --git a/src/renderer/types/mock-interview.ts b/src/renderer/types/mock-interview.ts index d7e124ac..48290f02 100644 --- a/src/renderer/types/mock-interview.ts +++ b/src/renderer/types/mock-interview.ts @@ -99,6 +99,8 @@ export interface MockInterviewSessionState { liveHints: LiveSuggestion[]; report: MockReport | null; reportError: string | null; + /** A retry of a failed report is in flight - see the main-process mirror. */ + rescoring: boolean; /** True once this session has been written to a file - see the main-process mirror. */ exported: boolean; error: string | null; From 392c9002599757ebb3699de36062828bc2644f0f Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 15 Sep 2026 10:48:57 -0400 Subject: [PATCH 4/6] test(mock): pin the report deadline and the retry Covers the deadline scaling with the turns being scored and staying bounded at both ends, and the retry: the in-flight shape staying on Finished rather than re-entering Scoring, a success clearing the error, a failure landing back where the first one did, a retry with nothing to recover not reaching the backend at all, and a retry abandoned by clear() writing nothing onto the session that replaced it. Refs #133 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL --- test/mock-report-retry.test.mjs | 177 ++++++++++++++++++++++++++++++++ test/run.mjs | 3 + 2 files changed, 180 insertions(+) create mode 100644 test/mock-report-retry.test.mjs diff --git a/test/mock-report-retry.test.mjs b/test/mock-report-retry.test.mjs new file mode 100644 index 00000000..537ac022 --- /dev/null +++ b/test/mock-report-retry.test.mjs @@ -0,0 +1,177 @@ +/** + * The report call's deadline, and the recovery when it is still not enough. + * + * Scoring is the one charged call in a mock interview whose duration scales with the session - + * `MockReport` carries a score, a justification and a full rewritten answer per question - and it + * used to run against a flat 60s wall clock. Long sessions hit that routinely, which is what + * "scoring sometimes times out" was, and the failure was terminal: the answers stayed on screen + * and stayed exportable, but the score they had already been billed for could not be obtained by + * any route short of running a second interview. + * + * Same fake-fetch harness as `mock-interview-state.test.mjs`, against the same service singleton. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const REPORT = { + overall_score: 74, + strengths: ['clear'], + gaps: ['depth'], + questions: [], +}; + +export async function run() { + const { check, failures } = createChecker('mock-report-retry'); + + const { mockReportTimeoutMs } = await loadMain('api/mock-interview.js'); + const { mockInterviewService } = await loadMain('services/mock-interview.service.js'); + const { MockInterviewState } = await loadMain('types/mock-interview.js'); + + // The deadline scales with the turns actually being scored, so one number no longer has to be + // right for a three-question session and a twelve-question one at once. + check('a short session gets more than the flat 60s it used to', mockReportTimeoutMs(1) > 60_000); + check( + 'and a longer one gets more than a shorter one', + mockReportTimeoutMs(8) > mockReportTimeoutMs(3) + ); + check( + 'the session length that used to fail now clears a minute comfortably', + mockReportTimeoutMs(10) >= 180_000 + ); + // Bounded at both ends: a zero-turn call still gets a real deadline, and a hung request cannot + // hold the Scoring spinner indefinitely just because the session was long. + check('a zero-question call still carries a deadline', mockReportTimeoutMs(0) >= 30_000); + check('and the ceiling holds', mockReportTimeoutMs(500) === mockReportTimeoutMs(1000)); + check('with a ceiling End is still a usable way out of', mockReportTimeoutMs(500) <= 300_000); + + const originalFetch = globalThis.fetch; + const state = { reportShouldFail: true, holdReport: null }; + let reportCalls = 0; + + globalThis.fetch = async (url) => { + const path = String(url); + if (path.includes('/mock-interview/question')) { + return jsonResponse({ text: 'A question', kind: 'technical' }); + } + if (path.includes('/mock-interview/turn')) { + return jsonResponse({ action: 'next', follow_up_question: '' }); + } + if (path.includes('/mock-interview/report')) { + reportCalls += 1; + if (state.holdReport) await state.holdReport; + if (state.reportShouldFail) return jsonResponse({ detail: 'error' }, 500); + return jsonResponse(REPORT); + } + return new Response(null, { status: 404 }); + }; + + const setup = { + role: 'Backend Engineer', + seniority: 'mid', + difficulty: 'standard', + question_count: 1, + }; + + const answerOneQuestion = async () => { + await mockInterviewService.start(setup); + await mockInterviewService.speechFinished(); + mockInterviewService.ingestAnswer('final', 'A real answer.'); + await mockInterviewService.answerFinished(); + }; + + let release = () => {}; + const holdNextReport = () => { + state.holdReport = new Promise((resolve) => { + release = resolve; + }); + }; + const releaseReport = async (pending) => { + release(); + state.holdReport = null; + await pending; + }; + + try { + // Reach Finished with a failed report, the shape a timeout leaves behind. + mockInterviewService.clear(); + await answerOneQuestion(); + check( + 'a failed report reaches Finished', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + check('with an error', mockInterviewService.getState().reportError !== null); + check('and nothing in flight', mockInterviewService.getState().rescoring === false); + + // The retry is held open so the in-flight shape can be read. It must stay on `Finished`: + // `Scoring` is an active session, which would re-arm the navigation lock and replace the + // report screen the candidate is looking at with the session screen. + holdNextReport(); + state.reportShouldFail = false; + const retry = mockInterviewService.retryScoring(); + await Promise.resolve(); + check('a retry in flight sets rescoring', mockInterviewService.getState().rescoring === true); + check( + 'and stays on Finished rather than re-entering Scoring', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + check('with the answers still on screen', mockInterviewService.getState().answers.length === 1); + await releaseReport(retry); + + check( + 'a successful retry produces the report', + mockInterviewService.getState().report !== null + ); + check('clears the error', mockInterviewService.getState().reportError === null); + check('and clears rescoring', mockInterviewService.getState().rescoring === false); + check( + 'and is still Finished', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + + // Nothing left to recover, so nothing to charge for: a retry against a report that already + // exists must not reach the backend at all. + const afterSuccess = reportCalls; + await mockInterviewService.retryScoring(); + check('a retry with no error on the session is a no-op', reportCalls === afterSuccess); + + // A failed retry lands back where the first failure did rather than stranding the session, + // and can be asked for again. + state.reportShouldFail = true; + mockInterviewService.clear(); + await answerOneQuestion(); + const beforeFailedRetry = reportCalls; + await mockInterviewService.retryScoring(); + check('a failed retry does reach the backend', reportCalls === beforeFailedRetry + 1); + check( + 'and leaves the session on Finished with an error, not rescoring', + mockInterviewService.getState().state === MockInterviewState.Finished && + mockInterviewService.getState().reportError !== null && + mockInterviewService.getState().rescoring === false + ); + + // `clear()` bumps the session sequence, so a retry the candidate has already navigated past + // cannot write its result onto the session that replaced it. + holdNextReport(); + state.reportShouldFail = false; + const abandoned = mockInterviewService.retryScoring(); + await Promise.resolve(); + mockInterviewService.clear(); + await releaseReport(abandoned); + check( + 'a retry abandoned by clear() writes nothing back', + mockInterviewService.getState().state === MockInterviewState.Idle && + mockInterviewService.getState().report === null + ); + } finally { + globalThis.fetch = originalFetch; + mockInterviewService.clear(); + } + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 2c68549a..8f0e258a 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -50,6 +50,9 @@ for (const module of [ // Drives appStateService.runningState and mockInterview together, so it must run after // mock-interview-state seeds no lasting mockInterview state of its own (mock-interview-state // clears the service on every branch, and the service's own clear() resets appState too). + // Same harness and the same service singleton again - it drives a session to a failed report + // and then retries it, so it sits with the other session-driving files. + './mock-report-retry.test.mjs', './mock-action-suggestion-block.test.mjs', './mock-interview-gate.test.mjs', // Swaps globalThis.fetch for the length of its own run and restores it in a finally, so it is From 58cfdc0706c5fe6e99ea952ff6be85fe92b1a8ce Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 15 Sep 2026 10:52:01 -0400 Subject: [PATCH 5/6] fix(mock): retire the export flag when a retried report lands A score that arrives after an export is content that file does not contain, which is the rule appendAnswer already follows for a new answer. Unreachable before retryScoring existed - a report only ever arrived before there was anything to export it from - but the report screen keeps Export beside the failure, so saving the answers and then retrying is an ordinary thing to do, and Done and Practise again would have waved the candidate past a score that was never written anywhere. Also gives the retry control the gap it needs: AlertDescription is a grid whose own gap-1 is sized for two lines of copy, not for copy followed by a button. Prettier reformatted three unrelated spots in report.tsx - the file is one this change touches, which is the rule the repo sets for formatting. Refs #133 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL --- src/main/api/mock-interview.ts | 5 +++- src/main/services/mock-interview.service.ts | 13 +++++++++-- src/renderer/pages/mock-interview/report.tsx | 24 ++++++++++++++++---- test/mock-report-retry.test.mjs | 15 ++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/api/mock-interview.ts b/src/main/api/mock-interview.ts index b026097d..c92fa78c 100644 --- a/src/main/api/mock-interview.ts +++ b/src/main/api/mock-interview.ts @@ -37,7 +37,10 @@ const MOCK_REPORT_MAX_MS = 240_000; export function mockReportTimeoutMs(questionCount: number): number { const questions = Number.isFinite(questionCount) ? Math.max(0, questionCount) : 0; - return Math.min(MOCK_REPORT_MAX_MS, MOCK_REPORT_BASE_MS + questions * MOCK_REPORT_PER_QUESTION_MS); + return Math.min( + MOCK_REPORT_MAX_MS, + MOCK_REPORT_BASE_MS + questions * MOCK_REPORT_PER_QUESTION_MS + ); } // The probe below is one unauthenticated round-trip against a route that is never going to do diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 9e77c076..b2d2d002 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -316,7 +316,9 @@ class MockInterviewService { if (response.status === HTTP_PAYMENT_REQUIRED) { this.lastQuestionError = response.error?.message || 'Not enough credits'; this.lastQuestionUnaffordable = true; - console.warn(`[MockInterviewService] question refused for credits: ${this.lastQuestionError}`); + console.warn( + `[MockInterviewService] question refused for credits: ${this.lastQuestionError}` + ); await this.finishToScoring(seq); return; } @@ -616,7 +618,8 @@ class MockInterviewService { // tests the follow-up against the report alone, on a session that is already inconsistent. const remainingQuestions = Math.max( 0, - (this.session.setup?.question_count ?? this.session.questionNumber) - this.session.questionNumber + (this.session.setup?.question_count ?? this.session.questionNumber) - + this.session.questionNumber ); let action: MockTurnAction = MockTurnAction.Next; @@ -800,6 +803,12 @@ class MockInterviewService { report: response.data, reportError: null, rescoring: false, + // The same rule `appendAnswer` follows: a score that arrives after an export is content + // that file does not contain. Unreachable before `retryScoring` existed - a report only + // ever arrived before there was anything to export it from - but the report screen offers + // Export beside the failure, so a candidate can save the answers, retry, and otherwise be + // waved past by Done and Practise again for a score that was never written anywhere. + exported: false, state: MockInterviewState.Finished, }; } catch (error) { diff --git a/src/renderer/pages/mock-interview/report.tsx b/src/renderer/pages/mock-interview/report.tsx index ee15f416..4319568a 100644 --- a/src/renderer/pages/mock-interview/report.tsx +++ b/src/renderer/pages/mock-interview/report.tsx @@ -4,7 +4,12 @@ import { toast } from 'sonner'; import { showExportSuccessToast } from '@/components/custom/export-success-toast'; import { SafeMarkdown } from '@/components/custom/safe-markdown'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -88,8 +93,10 @@ export function ReportScreen({ {reportError && ( - - + {/* `gap-3` overrides AlertDescription's own `gap-1`: it is a grid, and the default + gap is sized for two lines of copy rather than copy followed by a control. */} + + The overall score could not be produced ({reportError}). Your answers are still shown below and can still be exported. @@ -206,7 +213,9 @@ export function ReportScreen({

-

Stronger answer

+

+ Stronger answer +

@@ -251,7 +260,12 @@ export function ReportScreen({
-