diff --git a/CLAUDE.md b/CLAUDE.md index 8ea2e338..681b65eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -547,7 +547,7 @@ tests are. Idle it arrived at on its own, and nothing in the renderer read it - which did not show while that branch was unreachable, and would have turned a blank screen into a silent bounce to the launch cards the moment it was. The case it exists for is a dead or muted microphone: the silence backstop -skips its way through a session whose questions have already been billed, `finishToScoring` resets +skips its way through a session that has been on the meter the whole time, `finishToScoring` resets with a message naming the microphone, and the candidate is owed it. `/mock-interview` reports it deduplicated by the message rather than from one place, because both places see the same string and neither can be dropped - `start()` writes it onto the session *and* throws, so the broadcast races @@ -665,53 +665,48 @@ sheet written for the live report's shape. Picking levels by nesting depth inste Answer", "Score" and "Stronger Answer" at H5, so three centred labels appeared over left-ranged body text in every question. -### What a mock interview costs - -A live interview is metered per minute; a mock one is priced per question, follow-up and report, -and its ASR socket is not metered at all. The reason is how the two spend their wall clock: every -minute of a real interview is a minute of value, while a mock session spends a large part of its -clock in `Generating`, `Speaking`, `Evaluating` and `Scoring` - none of which the candidate can act -during - and most of the rest on think-time, which is the behaviour the feature exists to train. -Billing that by the second charges for the app talking to itself, and leaves the candidate unable -to find out what a session costs before starting it. - -**The prices arrive on the ping** (`AppState.mockPricing`, from `ClientPingResponse.mock_pricing`) -rather than being mirrored as constants, because the backend owns them and a stale copy here would -quote a number the user is not charged. `undefined` is not free and is not zero - it means the -backend predates per-turn pricing and is still metering a mock by the minute, so the client quotes -nothing and gates nothing, which is exactly what it did before any of this existed. - -**Two numbers are quoted, and the smaller one is the promise.** `mockSessionPrice()` is every -question plus the report, and it is what the start gate reserves; `mockSessionCeiling()` adds the -maximum follow-ups and is the number you are never charged more than. Follow-ups are charged only -as they are asked, and the backend declines one rather than let it eat into the rest of the -session - so quoting the ceiling as the price would refuse a five-question mock to someone holding -200 credits for a session that will almost certainly cost 170. - -**A length the balance cannot cover is disabled in the picker, not refused on Start.** The answer -to "not enough credits" is then a shorter interview the candidate can choose on the spot rather -than a dead end. `checkCanStart`'s own credit check is the backstop for the case where *every* -length is out of reach, and it carries the route out (a Buy credits action). The home screen's mock -card says the same thing one level earlier, against the shortest session there is, so the candidate -is not sent into a dialog in which nothing is selectable. - -**A 402 is not retried.** `generateNextQuestion` retries once on failure, which is right for a -provider blip and pointless for a balance: a second attempt cannot succeed and only doubles the -wait before the candidate is told. It is also reported differently - the backend's message already -names the price and the balance, so it is passed through rather than prefixed with "could not -generate the first question", which describes a fault the candidate does not have. - -**The client declares how it expects to be billed, and it is the only party that can.** -`MockBilling.PerTurn` goes on all three charged requests and `metered=0` goes on the mock socket, -because only this side knows whether that socket is asking to be metered. An older backend ignores -both and bills by the minute; an older client sends neither and is billed by the minute. No mixed -state charges twice, which is the only outcome that must not happen. - -The mock socket sends **both** `channels=1` and `metered=0`, and the pairing is what a tidy-up -breaks. `channels=1` looks redundant once `metered=0` exists, but it is what keeps the older -behaviour correct against a backend that ignores `metered`: the default of two assumes the live -session's pair of sockets, so dropping it would halve every mock interview's bill on every -deployment not yet updated. `test/mock-billing-contract.test.mjs` pins both halves. +### What an interview costs + +**Every interview, live or mock, is billed by the minute on its ASR socket, at the same rate, and +nothing else charges.** A mock used to be priced per question, follow-up and report, with +`billing=per_turn` on its requests and `metered=0` on its socket; both are gone. The mock socket is +open from Start until the session reaches Idle or Finished (`use-mock-interview.ts`), so metering it +already covers question generation, speaking, scoring and the report. + +The mock socket still sends `channels=1`, and that is the one parameter a tidy-up must not drop: +the backend divides the per-minute price by the number of sockets a session holds, defaulting to +the live session's two, so without it a mock pays half. `test/mock-billing-contract.test.mjs` +pins it, and that nothing still declares per-turn billing - a backend that predates the switch +would honour such a declaration *and* run the clock. + +**One start rule for both** (`lib/credit-gate.ts`): at least one minute of credit. The home cards, +the live control panel and the mock setup form all use it and send the user to Buy credits. The +backend refuses only at zero, because it cannot tell a new session from a reconnect inside one; this +side can, so the minimum lives here. An unknown balance (before the first ping) is let through +rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and +credits and says how far the balance goes, rather than disabling lengths. + +**The backend ends a socket at zero with close code 4402** (`WS_CLOSE_INSUFFICIENT_CREDITS`), and +`AudioWsStream` must never reconnect on it - a reconnect is refused the same way, and the backoff +loop would retry it until the user stopped the session by hand. The code is checked in the bound +close handler *before* `active` (a socket refused during `start()` closes before the graph is +built, and `start()` throws `OutOfCreditsError`), before open, and in the retry loop. A 4402 lost on +the network arrives as a 1006, the reconnect is refused with 4402, and the session ends one round +trip later. Live reports it once per session (both channels close), and only once both channels have +started - a refusal during start is `start()` rejecting, not a stop. A report that lands while +`runningState` is still `Starting` is held until `Running`: `startAssistant` writes `Running` a few +seconds after the sockets are up, and a stop inside that window was overwritten by it. Out of stealth it ends through +`useEndLiveSession`, so the transcript can still be saved; in stealth it only stops, like the stop +hotkey, because a save prompt and a jump to the dashboard do not belong on a screen share. The +subscription is in the control panel because its hooks run in stealth mode too. Mock ends through `endSession()`, keeping the +answer in progress, and ignores it once scoring has begun. Its socket opens before main is asked +to start, so a refusal that lands while the session still reads Idle is held and acted on once +main's start resolves. `test/out-of-credits.test.mjs` pins all +of it. + +`useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance. Sockets +also carry `kind=mock` and a per-session `client_session_id`, which the backend uses only to label +and group its ledger rows. ### Window and Stealth Mode diff --git a/src/main/ipc/app-state.ts b/src/main/ipc/app-state.ts index ed4a36b9..202aa1fe 100644 --- a/src/main/ipc/app-state.ts +++ b/src/main/ipc/app-state.ts @@ -14,7 +14,7 @@ import { type AppState } from '../types/app-state.js'; * `FAILURE_INTERVAL`/`SUCCESS_INTERVAL` later) silently overwrote it - an unguarded path onto a * financial field that happened to have no caller today. */ -const SERVER_OWNED_KEYS = ['credits', 'creditsPerMinute', 'userRole', 'mockPricing'] as const; +const SERVER_OWNED_KEYS = ['credits', 'creditsPerMinute', 'userRole'] as const; function stripServerOwnedFields(updates: Partial): Partial { const sanitized = { ...updates }; diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 52be0666..af583d6b 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -63,13 +63,9 @@ export class HealthCheckService { credits: res.data?.credits, userRole: res.data?.user_role, providedLLMModel: res.data?.provided_llm_model, - // Undefined on a backend that predates per-turn pricing, and carried through as - // undefined rather than defaulted: the mock setup dialog reads the absence as "this - // deployment still meters a mock by the minute", and a zero would read as free. - mockPricing: res.data?.mock_pricing, - // Same reasoning: undefined here means "not answered yet", not free and not the - // compiled-in default - the renderer falls back to its own mirror for that case. An - // unusable rate is folded into that same absence; see `usableRate`. + // Undefined here means "not answered yet", not free and not the compiled-in default - + // the renderer falls back to its own mirror for that case. An unusable rate is folded + // into that same absence; see `usableRate`. creditsPerMinute: usableRate(res.data?.credits_per_minute), }); } catch (error) { @@ -199,7 +195,6 @@ export class HealthCheckService { credits: res.data?.credits, providedLLMModel: res.data?.provided_llm_model, userRole: res.data?.user_role, - mockPricing: res.data?.mock_pricing, creditsPerMinute: usableRate(res.data?.credits_per_minute), }); } diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 02a8b19a..616961db 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -26,7 +26,6 @@ import { GenerateMockReportRequest, isMockInterviewSessionActive, MockAnswer, - MockBilling, MockCurrentQuestion, MockInterviewSessionState, MockInterviewSetup, @@ -63,7 +62,7 @@ function describeApiError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** A charge the balance could not cover - see `generateNextQuestion`. */ +/** A refusal for credits - no longer sent by any backend; see `generateNextQuestion`. */ const HTTP_PAYMENT_REQUIRED = 402; function initialSession(): MockInterviewSessionState { @@ -294,10 +293,6 @@ class MockInterviewService { // The number this question will carry once installed. `installQuestion` advances the // counter only for a new question, so a follow-up keeps the one on screen. question_number: isFollowUp ? this.session.questionNumber : this.session.questionNumber + 1, - // This client pays per turn and its ASR socket asks not to be metered, so it says so on - // every request that is charged for. An older backend ignores the field and meters the - // socket as it always did; see `MockBilling`. - billing: MockBilling.PerTurn, }; let attempt = 0; @@ -309,11 +304,12 @@ class MockInterviewService { const response = await this.api.generateQuestion(request); if (seq !== this.sessionSeq) return; - // Not retried, and not lumped in with the failures below. A balance that cannot pay for - // this question cannot pay for it a second time either, so a retry only doubles the wait - // before the candidate is told - and what they are told is already the whole answer, - // naming the price and their balance, so it is passed through rather than prefixed with - // "could not generate the question", which describes a fault they do not have. + // Not expected from any backend now: a mock is billed by the minute on its ASR socket, + // the current backend never answers 402 here, and an older one did only for a client + // that declared per-turn billing, which this one does not. Kept as a guard: if a refusal + // for credits ever does arrive, it is not retried (a balance that cannot pay once cannot + // pay twice) and its message is passed through rather than prefixed with "could not + // generate the question", which describes a fault the candidate does not have. if (response.status === HTTP_PAYMENT_REQUIRED) { this.lastQuestionError = response.error?.message || uiStrings().mockErrors.notEnoughCredits; @@ -614,16 +610,6 @@ class MockInterviewService { this.setState(MockInterviewState.Evaluating); this.broadcast(); - // What the rest of the session still owes, for the follow-up's billing test below. `setup` is - // non-null for any session that has reached `Listening`, but the type cannot know that, and - // reading a missing one as "nothing left to ask" is the harmless direction: the backend then - // 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 - ); - let action: MockTurnAction = MockTurnAction.Next; let followUpQuestion = ''; try { @@ -633,11 +619,6 @@ class MockInterviewService { answer: answerText, kind: question.kind, follow_up_count: this.followUpCount, - // Lets the backend decline a follow-up that would leave the session unable to finish the - // questions it was quoted. Counted off `questionNumber`, which a follow-up deliberately - // does not advance. - remaining_questions: remainingQuestions, - billing: MockBilling.PerTurn, }; const response = await this.api.evaluateTurn(request); if (seq !== this.sessionSeq) return; @@ -791,7 +772,6 @@ class MockInterviewService { profile_data: interviewConfig.profileData, context: interviewConfig.context, questions: this.session.answers.map((a) => ({ question: a.question, answer: a.answer })), - billing: MockBilling.PerTurn, }; const response = await this.api.generateReport(request); if (seq !== this.sessionSeq) return; diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 6c1bf6d2..02f5b59b 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -2,7 +2,7 @@ * Application State Types */ -import { MockPricing, UserRole } from './health-check.js'; +import { UserRole } from './health-check.js'; import { Language } from './language.js'; import { SuggestionMode } from './llm.js'; import { MockInterviewSessionState } from './mock-interview.js'; @@ -178,16 +178,7 @@ export interface AppState { */ mockInterviewSupported: boolean | null; /** - * What a mock interview costs per unit of work, or `undefined` before the backend has said. - * - * `undefined` is not free and is not a price of zero - it means this backend predates per-turn - * pricing and is still metering a mock session by the minute on its ASR socket. The client - * reads it as "quote nothing and gate nothing", which is exactly the behaviour it had before - * any of this existed. See `MockBilling` on the backend for the whole compatibility story. - */ - mockPricing?: MockPricing; - /** - * The price of a live interview, in credits per minute, or `undefined` before the backend has + * The price of an interview, live or mock, in credits per minute, or `undefined` before the backend has * said. * * `undefined` does not mean free and does not mean the shipped default - it means this ping diff --git a/src/main/types/health-check.ts b/src/main/types/health-check.ts index 9bf66ba9..32f6d894 100644 --- a/src/main/types/health-check.ts +++ b/src/main/types/health-check.ts @@ -8,31 +8,12 @@ export enum UserRole { Admin = 'admin', } -/** - * What a mock interview costs, in credits per unit of work delivered. - * - * A mock is priced per question, follow-up and report rather than by the clock: most of a mock - * session's wall time is the product generating a question, speaking it, scoring the turn or - * writing the report, none of which the candidate can act during, and the rest is think-time, - * which is the behaviour the feature exists to train. - * - * Sent on the ping because the client needs it before the candidate commits - the setup dialog - * quotes the session and refuses one the balance cannot see through to its report. - */ -export interface MockPricing { - per_question: number; - per_follow_up: number; - per_report: number; -} - export interface ClientPingResponse { credits: number; provided_llm_model: string; user_role: UserRole; - /** Absent on a backend that predates per-turn pricing - see `AppState.mockPricing`. */ - mock_pricing?: MockPricing; /** - * The price of a live interview, in credits per minute. Optional here even though the + * The price of an interview, live or mock, in credits per minute. Optional here even though the * backend always sends it now: this client ships independently of the hand-deployed backend, * so an older deployment still answers without it - see `AppState.creditsPerMinute` for the * fallback that case reads as. diff --git a/src/main/types/mock-interview.ts b/src/main/types/mock-interview.ts index e97f204e..5dd9c375 100644 --- a/src/main/types/mock-interview.ts +++ b/src/main/types/mock-interview.ts @@ -110,29 +110,7 @@ interface MockQuestionHistoryEntry { answer: string; } -/** - * How this client expects the work it is asking for to be paid for. - * - * The switch that makes per-turn pricing safe across a hand-deployed backend and a client that - * ships on its own schedule. A mock interview used to be billed by the minute, on the wall clock - * of an ASR socket held open for the whole session; it is now billed per question, follow-up and - * report, and its socket asks not to be metered at all (`metered=0`). - * - * Only this side knows which of those two it is doing, because only this side opens the socket - - * so it says. An older client that says nothing is billed exactly as it was, and a newer client - * against an older backend has both halves ignored and is also billed exactly as it was. No - * combination charges twice, which is the only outcome that must not happen. - */ -export enum MockBilling { - PerTurn = 'per_turn', -} - -/** The billing declaration shared by the three mock requests that are charged for. */ -interface BilledMockRequest extends LLMRequest { - billing: MockBilling; -} - -export interface GenerateMockQuestionRequest extends BilledMockRequest { +export interface GenerateMockQuestionRequest extends LLMRequest { setup: MockInterviewSetup; profile_data: string; context: string; @@ -149,23 +127,14 @@ export interface GenerateMockQuestionRequest extends BilledMockRequest { question_number: number; } -export interface EvaluateMockTurnRequest extends BilledMockRequest { +export interface EvaluateMockTurnRequest extends LLMRequest { question: string; answer: string; kind: MockQuestionKind; follow_up_count: number; - /** - * How many questions are still to be asked after this turn. - * - * Billing only. A follow-up is charged as an extra on top of the price the session was quoted, - * so the backend declines one when paying for it would leave the session unable to finish the - * questions it promised. It cannot work that out on its own: `history` counts turns, so it runs - * ahead of the question number wherever a follow-up was asked. - */ - remaining_questions: number; } -export interface GenerateMockReportRequest extends BilledMockRequest { +export interface GenerateMockReportRequest extends LLMRequest { setup: MockInterviewSetup; profile_data: string; context: string; diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 530cc627..8ad8ee79 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -8,10 +8,13 @@ import { useAudioInputDevices } from '@/hooks/use-audio-devices'; import { useConfigStore } from '@/hooks/use-config-store'; import { useEndLiveSession } from '@/hooks/use-end-live-session'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useLowBalanceWarning } from '@/hooks/use-low-balance-warning'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useT } from '@/i18n'; import { isMac } from '@/lib/consts'; +import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; import { getElectron } from '@/lib/utils'; +import { liveTranscriptionService } from '@/services/live-transcription.service'; import { RunningState } from '@/types/app-state'; import HeadphoneNoticeDialog from '../headphone-notice-dialog'; @@ -28,7 +31,7 @@ export default function ControlPanel() { const isStealth = useIsStealthMode(); const navigate = useNavigate(); const location = useLocation(); - const { startAssistant } = useAssistantService(); + const { startAssistant, stopAssistant } = useAssistantService(); const endLiveSession = useEndLiveSession(); const { runningState, appState } = useAppState(); const { config } = useConfigStore(); @@ -38,6 +41,71 @@ export default function ControlPanel() { const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); + useLowBalanceWarning(runningState === RunningState.Running); + + // The backend closed the live sockets because the balance ran out. Subscribed here rather than + // on the page because this is where `endLiveSession` lives, and these hooks still run in stealth + // mode - only the render is skipped. Read through refs so the subscription is made once, not on + // every render that hands `useEndLiveSession` a new closure. + // + // Out of stealth it ends the way Stop ends it, so the candidate is offered the transcript before + // it goes. In stealth it ends the way the stop hotkey does - stopped, nothing more - because + // stealth means a screen share is likely live, and a modal save prompt and a jump to the + // dashboard are the last things to put on it. The transcript stays, and the next Start asks. + // + // A report that arrives while the session is still `Starting` is held until it is `Running`. + // `startAssistant` waits a few seconds after the sockets are up before it writes `Running`, so + // a stop inside that window was overwritten by the start path - a console showing a live + // session with no sockets behind it. A start that fails instead drops the held report, since + // its own teardown already ended the session. + const endLiveSessionRef = useRef(endLiveSession); + const stopAssistantRef = useRef(stopAssistant); + const isStealthRef = useRef(isStealth); + const runningStateRef = useRef(runningState); + const outOfCreditsPending = useRef(false); + useLayoutEffect(() => { + endLiveSessionRef.current = endLiveSession; + stopAssistantRef.current = stopAssistant; + isStealthRef.current = isStealth; + runningStateRef.current = runningState; + }); + + const endForCredits = useCallback(() => { + toast.error(t.creditGate.outOfCredits, { + description: t.creditGate.outOfCreditsHint, + action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + }); + if (isStealthRef.current) { + void stopAssistantRef.current().catch((error) => { + console.error('Failed to stop the assistant after running out of credits:', error); + }); + } else { + void endLiveSessionRef.current(); + } + }, [navigate, t]); + + useEffect( + () => + liveTranscriptionService.onOutOfCredits(() => { + if (runningStateRef.current === RunningState.Starting) { + outOfCreditsPending.current = true; + return; + } + endForCredits(); + }), + [endForCredits] + ); + + useEffect(() => { + if (!outOfCreditsPending.current) return; + if (runningState === RunningState.Running) { + outOfCreditsPending.current = false; + endForCredits(); + } else if (runningState === RunningState.Idle) { + outOfCreditsPending.current = false; + } + }, [runningState, endForCredits]); + // Arriving here is how a live session gets started: the home screen and the command palette // ask for it through router state rather than owning a copy of the sequence below. // @@ -110,6 +178,19 @@ export default function ControlPanel() { return false; } } + + // Last, like the mock form's, and the same rule: at least a minute of credit. The backend + // closes the socket at zero, so a session started on less would be cut off almost at once. + if (!canStartSession(appState?.credits, appState?.creditsPerMinute)) { + toast.error(t.creditGate.tooLow, { + description: t.creditGate.tooLowHint( + minimumStartCredits(appState?.creditsPerMinute), + appState?.credits ?? 0 + ), + action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + }); + return false; + } return true; }; diff --git a/src/renderer/components/custom/mock-interview-setup-fields.tsx b/src/renderer/components/custom/mock-interview-setup-fields.tsx index bd67b701..e2c6ccea 100644 --- a/src/renderer/components/custom/mock-interview-setup-fields.tsx +++ b/src/renderer/components/custom/mock-interview-setup-fields.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/select'; import type { MockInterviewSetupForm } from '@/hooks/use-mock-interview-setup-form'; import { useT } from '@/i18n'; +import { effectiveRate, minutesCovered, mockSessionMinutes } from '@/lib/credit-gate'; import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; /** @@ -48,13 +49,11 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor questionCount, setQuestionCount, credits, - priceOf, - ceilingOf, - canAfford, + creditsPerMinute, } = form; - const price = priceOf(questionCount); - const ceiling = ceilingOf(questionCount); + const estimatedMinutes = mockSessionMinutes(questionCount); + const coveredMinutes = credits === undefined ? null : minutesCovered(credits, creditsPerMinute); return (
@@ -84,13 +83,9 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor - {/* A length the balance cannot see through to its report is disabled rather than - left to be refused on Start, so the answer to "not enough credits" is a shorter - interview the user can pick right here instead of a dead end. */} {QUESTION_COUNTS.map((n) => ( - - {t.mock.setup.questionOption(n, Math.round(n * 2.5))} - {canAfford(n) ? '' : t.mock.setup.cannotAfford} + + {t.mock.setup.questionOption(n, mockSessionMinutes(n))} ))} @@ -98,20 +93,19 @@ export function MockInterviewSetupFields({ form }: { form: MockInterviewSetupFor
- {/* What this will cost, before the candidate commits to it. - Two numbers, and the smaller one is the promise: every question and the report are - guaranteed once the session starts, and follow-ups are charged only as they are asked - - the backend declines one rather than let it eat into the rest of the session. So the - ceiling is the number you are never charged more than, not the one to expect. - Nothing is shown at all against a backend that predates per-turn pricing, which still - meters a mock by the minute and has no per-question price to quote. */} - {price !== null && ceiling !== null && ( -

- {t.mock.setup.priceLead} - {t.mock.setup.price(price)} - {ceiling > price && t.mock.setup.priceCeiling(ceiling)}. {t.mock.setup.balance(credits)} -

- )} + {/* An estimate, not a quote: a mock is billed by the minute for the time it takes, at the + same rate as a live interview, and ends with its report when the credits run out. So no + length is refused here - the candidate is told how far the balance goes instead. */} +

+ {t.mock.setup.estimate( + estimatedMinutes, + estimatedMinutes * effectiveRate(creditsPerMinute) + )} + {coveredMinutes !== null && <> {t.mock.setup.covers(coveredMinutes)}} + {coveredMinutes !== null && coveredMinutes < estimatedMinutes && ( + <> {t.mock.setup.endsEarly} + )} +

{/* No htmlFor: this labels the group via aria-labelledby below, not one control. */} diff --git a/src/renderer/hooks/use-app-state.tsx b/src/renderer/hooks/use-app-state.tsx index 9b919800..d4bd5ff3 100644 --- a/src/renderer/hooks/use-app-state.tsx +++ b/src/renderer/hooks/use-app-state.tsx @@ -41,13 +41,6 @@ class AppStateManager { credits: raw.credits, userRole: raw.userRole, providedLLMModel: raw.providedLLMModel, - // Was missing from this object entirely - every write to `AppState.mockPricing` from main - // was silently dropped here, so the mock-interview affordability gate this field exists to - // drive never actually engaged: `pricing` in the setup form and `appState?.mockPricing` on - // the home card were always undefined, which both read the same way "this backend predates - // per-turn pricing" does - quote nothing, gate nothing - even against a backend that does - // send it. - mockPricing: raw.mockPricing, creditsPerMinute: raw.creditsPerMinute, interviewConfig: raw.interviewConfig ?? { fullName: '', hasProfileData: false }, interviewConfigLoaded: raw.interviewConfigLoaded ?? false, diff --git a/src/renderer/hooks/use-interview-language.ts b/src/renderer/hooks/use-interview-language.ts index 15b075dd..762d5d3e 100644 --- a/src/renderer/hooks/use-interview-language.ts +++ b/src/renderer/hooks/use-interview-language.ts @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react'; import { toast } from 'sonner'; import { currentTranslation } from '@/i18n'; -import { liveTranscriptionService } from '@/services/live-transcription.service'; +import { liveTranscriptionService, OutOfCreditsError } from '@/services/live-transcription.service'; import { getLanguageOption, type Language } from '@/types/language'; import { useConfigStore } from './use-config-store'; @@ -65,6 +65,9 @@ export function useInterviewLanguage() { setReconnectFailed(false); } catch (e) { if (seq !== switchSeq.current) return; + // Refused for credits: the session is ending, and the out-of-credits stop says so. Warning + // that the language only half applied would be both wrong and a second toast. + if (e instanceof OutOfCreditsError) return; console.error('Failed to switch transcription language', e); setReconnectFailed(true); const t = currentTranslation(); diff --git a/src/renderer/hooks/use-low-balance-warning.ts b/src/renderer/hooks/use-low-balance-warning.ts new file mode 100644 index 00000000..87cfce61 --- /dev/null +++ b/src/renderer/hooks/use-low-balance-warning.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +import { useAppState } from '@/hooks/use-app-state'; +import { useT } from '@/i18n'; +import { minutesCovered } from '@/lib/credit-gate'; + +/** Minutes left at which each warning fires, largest first. */ +export const LOW_BALANCE_WARNING_MINUTES = [5, 1] as const; + +/** + * Warn, once each, when a running session's balance falls to five minutes and to one. + * + * The backend ends the session at zero credits, live or mock, and an interview cut off without + * notice is the worst way to find that out. The title bar already turns the remaining time yellow + * under five minutes, but nobody is looking at the title bar mid-answer; a toast is the one thing + * on screen that interrupts. + * + * Read off the balance the ping refreshes every few seconds, so a warning can lag the real + * balance by that much - harmless, since the stop itself is enforced by the backend. A session + * that starts already below a threshold gets that warning at once, and only the lowest one it is + * under: starting with half a minute left says "one minute", not "five" and then "one". + * + * `active` is whether a session is running now; the warnings re-arm each time it turns true. + */ +export function useLowBalanceWarning(active: boolean) { + const t = useT(); + const { appState } = useAppState(); + const credits = appState?.credits; + const creditsPerMinute = appState?.creditsPerMinute; + + // The thresholds already warned about in this session. + const warned = useRef(new Set()); + + useEffect(() => { + if (!active) { + warned.current.clear(); + return; + } + if (credits === undefined) return; + + const minutes = minutesCovered(credits, creditsPerMinute); + const crossed = LOW_BALANCE_WARNING_MINUTES.filter((threshold) => minutes < threshold + 1); + if (crossed.length === 0) return; + + // The lowest threshold crossed is the one worth saying; the ones above it are marked too, so + // they do not fire later out of order. + const lowest = crossed[crossed.length - 1]; + if (warned.current.has(lowest)) return; + crossed.forEach((threshold) => warned.current.add(threshold)); + + toast.warning(t.creditGate.lowBalance(Math.max(minutes, 1)), { + description: t.creditGate.lowBalanceHint, + }); + }, [active, credits, creditsPerMinute, t]); +} diff --git a/src/renderer/hooks/use-mock-interview-setup-form.ts b/src/renderer/hooks/use-mock-interview-setup-form.ts index e3bcff42..8b2ca9df 100644 --- a/src/renderer/hooks/use-mock-interview-setup-form.ts +++ b/src/renderer/hooks/use-mock-interview-setup-form.ts @@ -6,9 +6,8 @@ import { useAppState } from '@/hooks/use-app-state'; import { useAudioInputDevices } from '@/hooks/use-audio-devices'; import { useConfigStore } from '@/hooks/use-config-store'; import { useT } from '@/i18n'; -import { MOCK_MAX_FOLLOW_UPS_PER_QUESTION } from '@/lib/consts'; +import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; import { getElectron } from '@/lib/utils'; -import { mockSessionCeiling, mockSessionPrice } from '@/types/app-state'; import type { MockInterviewSetup } from '@/types/mock-interview'; import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; @@ -38,31 +37,8 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = const [starting, setStarting] = useState(false); const [headphoneNoticeOpen, setHeadphoneNoticeOpen] = useState(false); - // What a session costs, or `null` on a backend that predates per-turn pricing - which still - // meters a mock by the minute, so there is nothing to quote and nothing to gate. Never read as - // free: no price and a price of zero are different answers. - const pricing = appState?.mockPricing ?? null; - const credits = appState?.credits ?? 0; - - /** What a mock of `count` questions is guaranteed to cost, or null when the backend has no prices. */ - const priceOf = (count: number): number | null => - pricing === null ? null : mockSessionPrice(pricing, count); - - /** The most it could cost, if every question drew the maximum number of follow-ups. */ - const ceilingOf = (count: number): number | null => - pricing === null ? null : mockSessionCeiling(pricing, count, MOCK_MAX_FOLLOW_UPS_PER_QUESTION); - - /** - * Whether this balance can see a session of `count` questions through to its report. - * - * Tested against the guaranteed price rather than the ceiling. Follow-ups are charged as they - * are delivered and declined by the backend when paying for one would eat into what remains, so - * reserving the worst case here would refuse a session that will almost certainly not cost it. - */ - const canAfford = (count: number): boolean => { - const price = priceOf(count); - return price === null || credits >= price; - }; + const credits = appState?.credits; + const creditsPerMinute = appState?.creditsPerMinute; const selectedAudioInputDeviceName = config?.audioInputDeviceName ?? ''; const noAudioInputDevices = audioDevicesReady && audioInputDevices.length === 0; @@ -96,16 +72,13 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = toast.error(t.mockStartChecks.deviceNotFound(selectedAudioInputDeviceName)); return false; } - // Last of the checks, and the only one with somewhere to send the user. A mock that stops - // half-way is worse than one that never began, so the whole session is paid for up front or - // not started - the same guarantee the backend enforces at the first question. The dialog - // already disables the counts this would refuse, so reaching here means every length is out - // of reach. - if (!canAfford(questionCount)) { - const price = priceOf(questionCount); - toast.error(t.mockStartChecks.unaffordable(questionCount), { - description: t.mockStartChecks.unaffordableHint(price ?? 0, credits), - action: { label: t.mockStartChecks.buyCredits, onClick: () => navigate('/payment') }, + // Last of the checks, and the only one with somewhere to send the user. Any length may be + // chosen: the session is billed by the minute and ends at zero with its report, so the + // setup screen says how far the balance goes rather than refusing a length. + if (!canStartSession(credits, creditsPerMinute)) { + toast.error(t.creditGate.tooLow, { + description: t.creditGate.tooLowHint(minimumStartCredits(creditsPerMinute), credits ?? 0), + action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, }); return false; } @@ -146,9 +119,7 @@ export function useMockInterviewSetupForm(onStart: (setup: MockInterviewSetup) = questionCount, setQuestionCount, credits, - priceOf, - ceilingOf, - canAfford, + creditsPerMinute, starting, headphoneNoticeOpen, setHeadphoneNoticeOpen, diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index 94917e20..8b8eb17f 100644 --- a/src/renderer/hooks/use-mock-interview.ts +++ b/src/renderer/hooks/use-mock-interview.ts @@ -1,5 +1,8 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { currentTranslation } from '@/i18n'; import { mockTranscriptionService } from '@/services/mock-transcription.service'; import { mockTtsService } from '@/services/mock-tts.service'; import { DEFAULT_LANGUAGE } from '@/types/language'; @@ -75,6 +78,56 @@ export function useMockInterview() { } }, [session?.state, session?.currentQuestion]); + // The backend closed the socket because the balance ran out. Ended the same way the End button + // ends it, so the answer being spoken is kept and the session goes on to its report - which is + // still delivered at zero, on the free model. Ignored once scoring has begun: the socket is not + // needed any more, and ending here would discard the report already being written. + // + // The socket opens before main is asked to start, so a refusal can land while the session is + // still Idle here. That one is held and acted on once main's start has resolved: ignoring it + // would run a whole session on a socket that will never reconnect. After that, Idle can still be + // what this side reads for a moment - the start resolves over one IPC message and the state + // that says the session is running arrives over another - so a refusal then is acted on at + // once rather than held for a check that has already run. + const navigate = useNavigate(); + const stateRef = useRef(null); + const outOfCreditsPending = useRef(false); + const mainStarted = useRef(false); + useLayoutEffect(() => { + stateRef.current = session?.state ?? null; + }); + + const endForCredits = useCallback(() => { + const t = currentTranslation(); + toast.error(t.creditGate.outOfCredits, { + description: t.creditGate.outOfCreditsHint, + action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + }); + void window.electronAPI?.mockInterview.endSession(); + }, [navigate]); + + useEffect( + () => + mockTranscriptionService.onOutOfCredits(() => { + const state = stateRef.current; + if (state === null || state === MockInterviewState.Idle) { + if (!captureRunningRef.current) return; + if (mainStarted.current) endForCredits(); + else outOfCreditsPending.current = true; + return; + } + if ( + state === MockInterviewState.Scoring || + state === MockInterviewState.Finished || + state === MockInterviewState.Stopping + ) { + return; + } + endForCredits(); + }), + [endForCredits] + ); + // Belt for a component unmounting mid-session (navigating away without going through // endSession first) - stops local audio so a stray track is not left open. Main's own state // is tidied up by the caller of endSession, not by this cleanup. @@ -96,6 +149,8 @@ export function useMockInterview() { const electron = window.electronAPI; if (!electron) throw new Error('Electron API not available'); + outOfCreditsPending.current = false; + mainStarted.current = false; await mockTranscriptionService.start( config?.audioInputDeviceName ?? '', config?.sessionToken ?? '', @@ -108,9 +163,16 @@ export function useMockInterview() { await electron.mockInterview.start(setup); } catch (error) { captureRunningRef.current = false; + outOfCreditsPending.current = false; await mockTranscriptionService.stop(); throw error; } + + mainStarted.current = true; + if (outOfCreditsPending.current) { + outOfCreditsPending.current = false; + endForCredits(); + } }; const retryScoring = async (): Promise => { diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 0d811ee6..afa843f1 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -238,8 +238,8 @@ export const en = { ready: 'The AI asks, you answer out loud, and you get a scored report at the end.', unsupported: 'Not available on this server yet. Update the app, or try again later.', liveRunning: 'Stop the live assistant first - the two cannot share your microphone.', - unaffordable: (price: number) => - `Not enough credits - the shortest mock costs ${price}. Buy more to practise.`, + unaffordable: (minimum: number) => + `Not enough credits - you need at least ${minimum} (1 minute) to start. Buy more to practise.`, }, live: { @@ -248,6 +248,8 @@ export const en = { ready: 'Transcribes your real interview and suggests answers as it happens.', running: 'Your live assistant is already running.', mockRunning: 'Finish the mock interview first - the two cannot share your microphone.', + unaffordable: (minimum: number) => + `Not enough credits - you need at least ${minimum} (1 minute) to start.`, }, accountLabel: 'Account', @@ -831,7 +833,6 @@ export const en = { /** `about 8 minutes` is the shape; the count drives the plural in both columns. */ questionOption: (count: number, minutes: number) => `${count} questions, about ${minutes} minutes`, - cannotAfford: ' - not enough credits', difficulty: 'Difficulty', difficultyOptions: { easy: { @@ -849,15 +850,12 @@ export const en = { }, languageDescription: 'What the interviewer asks in, what is transcribed, and what your feedback comes back in.', - /** - * Two numbers, and the smaller one is the promise: every question and the report are - * guaranteed once the session starts, while follow-ups are charged only as they are asked. - */ - price: (price: number) => `${price} credits`, - priceLead: 'Costs ', - priceCeiling: (ceiling: number) => - `, up to ${ceiling} if the interviewer follows up on every answer`, - balance: (credits: number) => `You have ${credits.toLocaleString()}.`, + /** An estimate, not a price: the session is billed by the minute for the time it takes. */ + estimate: (minutes: number, credits: number) => + `Usually about ${minutes} minutes, about ${credits.toLocaleString()} credits.`, + covers: (minutes: number) => + `Your balance covers about ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`, + endsEarly: 'The session ends when your credits run out, and you still get your report.', }, session: { @@ -1131,10 +1129,18 @@ export const en = { mockStartChecks: { deviceNotFound: (deviceName: string) => `Audio input device "${deviceName}" is not found. Choose a different one from the main screen's audio settings.`, - unaffordable: (questionCount: number) => - `Not enough credits for a ${questionCount}-question mock interview`, - unaffordableHint: (price: number, credits: number) => - `It costs ${price} credits and you have ${credits}.`, + }, + + /** The one start rule live and mock share: at least one minute of credit. */ + creditGate: { + tooLow: 'Not enough credits to start', + outOfCredits: 'Out of credits - the session has ended', + outOfCreditsHint: 'Buy credits to start another one.', + lowBalance: (minutes: number) => + `About ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of credit left`, + lowBalanceHint: 'The session ends when your credits run out.', + tooLowHint: (minimum: number, credits: number) => + `A session needs at least ${minimum} credits (1 minute). You have ${credits}.`, buyCredits: 'Buy credits', }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index bd606943..1f88c036 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -240,8 +240,8 @@ export const ru: Translation = { unsupported: 'Пока недоступно на этом сервере. Обновите приложение или попробуйте позже.', liveRunning: 'Сначала остановите живого ассистента - они не могут использовать микрофон одновременно.', - unaffordable: (price: number) => - `Недостаточно кредитов - самое короткое пробное собеседование стоит ${price} ${plural(price, 'кредит', 'кредита', 'кредитов')}. Пополните баланс, чтобы тренироваться.`, + unaffordable: (minimum: number) => + `Недостаточно кредитов - для начала нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута). Пополните баланс, чтобы тренироваться.`, }, live: { @@ -251,6 +251,8 @@ export const ru: Translation = { running: 'Ассистент уже работает.', mockRunning: 'Сначала завершите пробное собеседование - они не могут использовать микрофон одновременно.', + unaffordable: (minimum: number) => + `Недостаточно кредитов - для начала нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута).`, }, accountLabel: 'Учётная запись', @@ -843,7 +845,6 @@ export const ru: Translation = { questions: 'Вопросы', questionOption: (count: number, minutes: number) => `${count} ${plural(count, 'вопрос', 'вопроса', 'вопросов')}, около ${minutes} ${plural(minutes, 'минуты', 'минут', 'минут')}`, - cannotAfford: ' - недостаточно кредитов', difficulty: 'Сложность', difficultyOptions: { easy: { @@ -861,11 +862,12 @@ export const ru: Translation = { }, languageDescription: 'На каком языке спрашивает интервьюер, что распознаётся и на каком языке приходит разбор.', - price: (price: number) => `${price} ${plural(price, 'кредит', 'кредита', 'кредитов')}`, - priceLead: 'Стоит ', - priceCeiling: (ceiling: number) => `, и до ${ceiling}, если интервьюер уточнит каждый ответ`, - balance: (credits: number) => - `У вас ${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}.`, + estimate: (minutes: number, credits: number) => + `Обычно около ${minutes} ${plural(minutes, 'минуты', 'минут', 'минут')}, примерно ${credits.toLocaleString('ru-RU')} ${plural(credits, 'кредит', 'кредита', 'кредитов')}.`, + covers: (minutes: number) => + `Вашего баланса хватит примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}.`, + endsEarly: + 'Когда кредиты закончатся, собеседование завершится, а отчёт вы всё равно получите.', }, session: { @@ -1134,10 +1136,17 @@ export const ru: Translation = { mockStartChecks: { deviceNotFound: (deviceName: string) => `Микрофон «${deviceName}» не найден. Выберите другой в настройках звука на главном экране.`, - unaffordable: (questionCount: number) => - `Недостаточно кредитов на пробное собеседование из ${questionCount} ${plural(questionCount, 'вопроса', 'вопросов', 'вопросов')}`, - unaffordableHint: (price: number, credits: number) => - `Оно стоит ${price} ${plural(price, 'кредит', 'кредита', 'кредитов')}, а у вас ${credits}.`, + }, + + creditGate: { + tooLow: 'Недостаточно кредитов для начала', + outOfCredits: 'Кредиты закончились - сессия завершена', + outOfCreditsHint: 'Пополните баланс, чтобы начать новую.', + lowBalance: (minutes: number) => + `Кредитов осталось примерно на ${minutes} ${plural(minutes, 'минуту', 'минуты', 'минут')}`, + lowBalanceHint: 'Когда кредиты закончатся, сессия завершится.', + tooLowHint: (minimum: number, credits: number) => + `Нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута), а у вас ${credits}.`, buyCredits: 'Купить кредиты', }, diff --git a/src/renderer/lib/consts.ts b/src/renderer/lib/consts.ts index 4204c5de..ba19c1dd 100644 --- a/src/renderer/lib/consts.ts +++ b/src/renderer/lib/consts.ts @@ -11,16 +11,6 @@ export const isMac = navigator.platform.toUpperCase().includes('MAC'); */ export const CREDITS_PER_MINUTE = 10; -/** - * The most follow-ups one mock-interview question can draw, mirrored from `main/consts.ts`. - * - * Used here only to quote the ceiling of a session's price. The cap itself is enforced in the - * main process, which is where the follow-up count lives; a copy that drifts high would overstate - * the quote and a copy that drifts low would understate it, so it is a mirror rather than a - * second opinion. - */ -export const MOCK_MAX_FOLLOW_UPS_PER_QUESTION = 2; - // maximum allowable RTT change (ms) before restarting audio agent export const MAX_RTT_DIFF = 50; export const MAX_AUDIO_DELAY_MS = 500; diff --git a/src/renderer/lib/credit-gate.ts b/src/renderer/lib/credit-gate.ts new file mode 100644 index 00000000..5d8e43eb --- /dev/null +++ b/src/renderer/lib/credit-gate.ts @@ -0,0 +1,53 @@ +import { CREDITS_PER_MINUTE } from './consts'; + +/** + * One rule for whether a session may start, shared by the live and mock start paths and the home + * screen cards that front them. + * + * Both kinds of interview are metered by the minute on their audio socket at the same rate, and + * the backend closes that socket when the balance reaches zero. A session started on a few seconds + * of credit would be cut off almost at once, so starting asks for at least one minute's worth. + * + * The backend refuses only at zero, deliberately: it cannot tell a new session from a reconnect + * in the middle of one, and refusing a reconnect with a few credits left would end an interview + * that has already been paid for. This side can tell, so the one-minute minimum lives here. + */ + +/** The rate to divide by: the backend's when it has said, the compiled-in mirror until then. */ +export function effectiveRate(creditsPerMinute: number | undefined): number { + return creditsPerMinute ?? CREDITS_PER_MINUTE; +} + +/** The credits a session needs before it may start. */ +export function minimumStartCredits(creditsPerMinute: number | undefined): number { + return effectiveRate(creditsPerMinute); +} + +/** + * Whether this balance may start a session. + * + * An unknown balance (before the first ping answers) is let through rather than refused: the + * backend still closes the socket at zero, and greying out both cards for the first seconds of + * every launch would be the worse failure. + */ +export function canStartSession( + credits: number | undefined, + creditsPerMinute: number | undefined +): boolean { + return credits === undefined || credits >= minimumStartCredits(creditsPerMinute); +} + +/** Whole minutes this balance pays for. */ +export function minutesCovered(credits: number, creditsPerMinute: number | undefined): number { + return Math.max(0, Math.floor(credits / effectiveRate(creditsPerMinute))); +} + +/** + * How long a mock interview of `questionCount` questions usually runs, in minutes. + * + * An estimate for the setup screen, not a limit: the session is billed for the time it actually + * takes, and a candidate who answers at length takes longer. + */ +export function mockSessionMinutes(questionCount: number): number { + return Math.round(questionCount * 2.5); +} diff --git a/src/renderer/pages/home/index.tsx b/src/renderer/pages/home/index.tsx index 0a5c1a2e..1eaf38e4 100644 --- a/src/renderer/pages/home/index.tsx +++ b/src/renderer/pages/home/index.tsx @@ -11,22 +11,12 @@ import useAuth from '@/hooks/use-auth'; import { useConfigStore } from '@/hooks/use-config-store'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { useT } from '@/i18n'; +import { canStartSession, minimumStartCredits } from '@/lib/credit-gate'; import { cn } from '@/lib/utils'; -import { mockSessionPrice, RunningState } from '@/types/app-state'; +import { RunningState } from '@/types/app-state'; import type { MockInterviewSetup } from '@/types/mock-interview'; import { isMockInterviewSessionActive } from '@/types/mock-interview'; -/** - * The shortest interview the setup dialog offers, and therefore the cheapest one there is. - * - * Mirrored from `QUESTION_COUNTS` in `mock-interview-setup-fields.tsx` rather than imported, so - * this card does not pull the whole form in to ask one question. A copy that drifts *up* would - * hide the card from someone who could afford a session; one that drifts down would offer a - * dialog in which every length is disabled - the second is the recoverable direction, and it is - * the one a stale copy of a list whose first entry only ever shrinks would take. - */ -const SHORTEST_MOCK_QUESTION_COUNT = 3; - interface LaunchCardProps { icon: React.ReactNode; title: string; @@ -130,17 +120,11 @@ export default function HomePage() { // unavailable would grey the card out for the first seconds of every launch. const mockUnsupported = appState?.mockInterviewSupported === false; - // The cheapest mock the setup dialog offers, so this card can say "you cannot afford any of - // them" rather than sending the candidate into a dialog where every length is disabled. - // - // Only when the backend has quoted prices. Without them a mock is still metered by the minute - // and there is nothing to check, which is what an older deployment does - so the absence must - // never read as unaffordable. - const shortestMockPrice = appState?.mockPricing - ? mockSessionPrice(appState.mockPricing, SHORTEST_MOCK_QUESTION_COUNT) - : null; - const mockUnaffordable = - shortestMockPrice !== null && (appState?.credits ?? 0) < shortestMockPrice; + // Both kinds of interview are billed by the minute at the same rate, so one rule decides + // whether either card may start a session: at least a minute of credit. A card that cannot + // start says so and goes to the payment page instead of into a start that would be refused. + const creditsTooLow = !canStartSession(appState?.credits, appState?.creditsPerMinute); + const minimumCredits = minimumStartCredits(appState?.creditsPerMinute); const [mockSetupOpen, setMockSetupOpen] = useState(false); const [signingOut, setSigningOut] = useState(false); @@ -259,11 +243,11 @@ export default function HomePage() { ? t.home.mock.unsupported : liveSessionActive ? t.home.mock.liveRunning - : mockUnaffordable - ? t.home.mock.unaffordable(shortestMockPrice) + : creditsTooLow + ? t.home.mock.unaffordable(minimumCredits) : t.home.mock.ready } - onClick={() => (mockUnaffordable ? navigate('/payment') : setMockSetupOpen(true))} + onClick={() => (creditsTooLow ? navigate('/payment') : setMockSetupOpen(true))} disabled={liveSessionActive || mockUnsupported} /> navigate('/payment') : handleStartLive } - onClick={handleStartLive} disabled={!liveSessionActive && mockSessionActive} />
diff --git a/src/renderer/pages/mock-interview/index.tsx b/src/renderer/pages/mock-interview/index.tsx index 00451c19..210c4dcf 100644 --- a/src/renderer/pages/mock-interview/index.tsx +++ b/src/renderer/pages/mock-interview/index.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { LoadingPage } from '@/components/custom/loading'; import { useAppState } from '@/hooks/use-app-state'; import { useInterviewNavigationLock } from '@/hooks/use-interview-lock'; +import { useLowBalanceWarning } from '@/hooks/use-low-balance-warning'; import { useMockInterview } from '@/hooks/use-mock-interview'; import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import useTools from '@/hooks/use-tools'; @@ -52,6 +53,12 @@ export default function MockInterviewPage() { const sessionRef = useRef(session); sessionRef.current = session; + // Not while scoring: the clock is nearly done by then, and a warning would land on top of the + // report the candidate is waiting for. + useLowBalanceWarning( + isMockInterviewSessionActive(session) && session?.state !== MockInterviewState.Scoring + ); + // The control bar's setup dialog has already collected and validated a setup and shown its own // headphone notice by the time it navigates here - it hands the result off through router state // rather than calling `startSession` itself, so only one `useMockInterview()` instance is ever diff --git a/src/renderer/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index 73e68aff..5d50c9fb 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -29,22 +29,47 @@ export const LIVE_STREAM_CHANNELS = 2; export const MOCK_STREAM_CHANNELS = 1; /** - * Whether this socket should be charged by the minute. + * The close code the backend ends a socket with when the balance is exhausted, on the handshake + * or mid-session. Mirrors `WS_CLOSE_INSUFFICIENT_CREDITS` in the backend's `app/cfg/asr.py`. * - * A live interview is: every minute of a real interview is a minute of value the product is - * delivering, and a clock is the right unit for it. A mock interview is not, and asks for - * `Unmetered` - it pays per question, follow-up and report instead, and metering it as well would - * bill one session twice. Most of a mock session's wall clock is the product generating a - * question, speaking it, scoring the turn or writing the report, none of which the candidate can - * act during, and the rest is think-time, which is the behaviour the feature exists to train. - * - * The backend does not take this on trust - see `stealthUnavailableReason`'s sibling reasoning in - * `asr.py`: it is honoured only while a paid-for mock turn holds a billing lease open, so a - * forged `metered=0` on a live session buys nothing. + * Never reconnected on: a reconnect is refused the same way, and the backoff loop would retry a + * socket that cannot open until the user stopped the session by hand. + */ +export const WS_CLOSE_INSUFFICIENT_CREDITS = 4402; + +/** Which kind of interview a socket belongs to. The backend uses it for the ledger only. */ +export type SessionKind = 'live' | 'mock'; + +/** + * Thrown when a socket is refused because the balance is exhausted. Its message is what a failed + * start shows, so it says the session could not start; a running session that runs out is + * reported through `onOutOfCredits` instead, with its own toast. */ -export const enum StreamMetering { - Metered = 1, - Unmetered = 0, +export class OutOfCreditsError extends Error { + constructor() { + super(currentTranslation().creditGate.tooLow); + this.name = 'OutOfCreditsError'; + } +} + +export interface AudioWsStreamOptions { + /** + * How many sockets the session this stream belongs to holds open, so the backend can charge + * the interview once rather than once per socket. Defaults to the live session's two. + */ + channels?: number; + /** Defaults to live. */ + kind?: SessionKind; + /** + * One id for every socket of one interview - both live channels, and every reconnect or + * language switch - so the backend's ledger can group them. + */ + clientSessionId?: string; + /** + * Called once when the backend closes a running socket because the balance is exhausted. Not + * called for a socket refused while `start()` is still running: `start()` throws instead. + */ + onOutOfCredits?: () => void; } /** @@ -61,17 +86,20 @@ export const enum StreamMetering { * it billed before, so sending it costs nothing against an older deployment and is the whole fix * against a current one. * - * `metered` follows `channels` exactly, and for the same reason it must: a mock session sends - * **both**. An older backend ignores `metered` and bills the socket by the minute, which is what - * `channels=1` is there to make correct; a current one honours `metered` and bills nothing here, - * at which point `channels` is moot. Dropping `channels` once `metered` existed would halve every - * mock interview's bill on every deployment that has not been updated yet. + * `kind` and `client_session_id` are for the backend's ledger only; an older backend ignores + * both. `kind` is sent only for a mock, since its absence means live. */ -function buildStreamingUrl(language: Language, channels: number, metering: StreamMetering): string { +function buildStreamingUrl( + language: Language, + channels: number, + kind: SessionKind, + clientSessionId: string | undefined +): string { const params = new URLSearchParams(); if (language !== DEFAULT_LANGUAGE) params.set('language', language); if (channels !== LIVE_STREAM_CHANNELS) params.set('channels', String(channels)); - if (metering !== StreamMetering.Metered) params.set('metered', String(metering)); + if (kind !== 'live') params.set('kind', kind); + if (clientSessionId) params.set('client_session_id', clientSessionId); const query = params.toString(); return query ? `${STREAMING_URL}?${query}` : STREAMING_URL; } @@ -146,6 +174,15 @@ export class AudioWsStream { // session behind it stays open for the life of the app. private switchSeq = 0; + // Set once the backend has closed this channel for credits. Nothing reconnects after that: the + // balance is what is missing, and every retry would be refused the same way. + private outOfCredits = false; + + private readonly channels: number; + private readonly kind: SessionKind; + private readonly clientSessionId: string | undefined; + private readonly onOutOfCredits: (() => void) | undefined; + constructor( private readonly channel: Channel, private stream: MediaStream, @@ -155,17 +192,17 @@ export class AudioWsStream { type: 'partial' | 'final'; text: string; }) => Promise, - /** - * How many sockets the session this stream belongs to holds open, so the backend can charge - * the interview once rather than once per socket. Defaults to the live session's two, which - * is what every caller wanted before a mock session existed. - */ - private readonly channels: number = LIVE_STREAM_CHANNELS, - private readonly metering: StreamMetering = StreamMetering.Metered - ) {} + options: AudioWsStreamOptions = {} + ) { + this.channels = options.channels ?? LIVE_STREAM_CHANNELS; + this.kind = options.kind ?? 'live'; + this.clientSessionId = options.clientSessionId; + this.onOutOfCredits = options.onOutOfCredits; + } async start() { this.stopping = false; + this.outOfCredits = false; await this.connectWithRetry(); this.ctx = new AudioContext(); @@ -207,6 +244,12 @@ export class AudioWsStream { this.workletNode.connect(this.monitorGain); this.monitorGain.connect(this.ctx.destination); + // Refused for credits while the graph above was being built: the socket opened (the backend + // accepts before it checks) and was closed straight after. Thrown rather than reported + // through `onOutOfCredits`, because the caller is still inside its start sequence and that is + // where a start that cannot happen belongs. + if (this.outOfCredits) throw new OutOfCreditsError(); + this.active = true; } @@ -358,6 +401,7 @@ export class AudioWsStream { if (this.stopping) { throw new Error(`WebSocket connection stopped for ${this.channel}`); } + if (this.outOfCredits) throw new OutOfCreditsError(); // Checked before the socket is built rather than only after. The next line assigns // `this.ws`, so a superseded loop waking from its backoff would otherwise overwrite the // socket the newer switch has already opened and leave that one unreferenced. @@ -368,6 +412,13 @@ export class AudioWsStream { await this.connectWebSocket(seq); return; } catch (error) { + // Not retried: the balance is what is missing, and a retry is refused the same way. + // Reported here so a reconnect refused before it opened ends the session like one + // closed mid-stream; during `start()` nothing is notified and the throw is the report. + if (error instanceof OutOfCreditsError) { + this.handleOutOfCredits(); + throw error; + } lastError = error; const delayMs = Math.min( WS_RETRY_BASE_DELAY_MS * Math.pow(2, attempt), @@ -390,7 +441,9 @@ export class AudioWsStream { return new Promise((resolve, reject) => { // Rebuilt per attempt rather than captured once, so a reconnect cannot outlive the // language the session opened with. - const ws = new WebSocket(buildStreamingUrl(this.language, this.channels, this.metering)); + const ws = new WebSocket( + buildStreamingUrl(this.language, this.channels, this.kind, this.clientSessionId) + ); this.ws = ws; let settled = false; @@ -432,6 +485,16 @@ export class AudioWsStream { window.clearTimeout(timeoutId); reject(new Error(`Failed to open websocket for ${this.channel}`)); }; + + // Before open, a close only matters if it says why. The backend accepts and then closes a + // socket it refuses for credits, so the open normally arrives first and the handler bound + // in `bindWebSocketHandlers` sees the code; this covers a close that beats it. + ws.onclose = (event) => { + if (settled || event.code !== WS_CLOSE_INSUFFICIENT_CREDITS) return; + settled = true; + window.clearTimeout(timeoutId); + reject(new OutOfCreditsError()); + }; }); } @@ -454,13 +517,24 @@ export class AudioWsStream { } }; - ws.onclose = () => { - if (this.stopping || !this.active) return; + ws.onclose = (event) => { + if (this.stopping) return; // A close from a socket that is no longer the current one is not a disconnect; it is the // tail of a replacement that already happened. Reconnecting on it would clobber the live // socket with a second one. if (this.ws !== ws) return; + // Checked ahead of `active`, which `start()` only sets once the audio graph is built - a + // socket refused for credits closes inside that window, and `start()` reads this flag to + // throw. If this close is lost on the network it arrives as a 1006 instead, the reconnect + // below is refused with this code, and the session ends here one round trip later. + if (event.code === WS_CLOSE_INSUFFICIENT_CREDITS) { + this.handleOutOfCredits(); + return; + } + + if (!this.active) return; + this.reportDisconnected(); // setLanguage owns both the report and the reconnect for the close it caused itself, and @@ -471,8 +545,18 @@ export class AudioWsStream { }; } + private handleOutOfCredits(): void { + if (this.outOfCredits) return; + this.outOfCredits = true; + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.active) this.onOutOfCredits?.(); + } + private scheduleReconnect(): void { - if (this.reconnectTimer !== null || this.stopping) return; + if (this.reconnectTimer !== null || this.stopping || this.outOfCredits) return; const seq = this.switchSeq; this.reconnectTimer = window.setTimeout(async () => { this.reconnectTimer = null; @@ -534,6 +618,28 @@ class LiveTranscriptionService { // slower one lands last and the session ends up on a device the user already moved off. private micSwitchSeq = 0; + // Both channels close for credits within an interval of each other, and the session must end + // once, with one prompt and one toast - so the event is per session, not per channel. + private outOfCreditsListeners = new Set<() => void>(); + private outOfCreditsReported = false; + + // Whether `start()` has finished bringing both channels up. A channel refused for credits while + // the other is still starting reports nothing: `start()` rejects, and the caller's start path + // tears the session down. Reporting it as well would run a stop alongside that teardown. + private running = false; + + /** Subscribe to the running session being closed for credits. Returns the unsubscribe. */ + onOutOfCredits(listener: () => void): () => void { + this.outOfCreditsListeners.add(listener); + return () => this.outOfCreditsListeners.delete(listener); + } + + private reportOutOfCredits(): void { + if (!this.running || this.outOfCreditsReported) return; + this.outOfCreditsReported = true; + this.outOfCreditsListeners.forEach((listener) => listener()); + } + async start( audioInputDeviceName: string, sessionToken: string, @@ -579,11 +685,25 @@ class LiveTranscriptionService { await electron.transcription.ingest(payload); }; - const micChannel = new AudioWsStream('ch_1', this.micStream, language, onTranscript); - const loopbackChannel = new AudioWsStream('ch_0', this.loopbackStream, language, onTranscript); + this.outOfCreditsReported = false; + this.running = false; + const options = { + kind: 'live' as const, + clientSessionId: crypto.randomUUID(), + onOutOfCredits: () => this.reportOutOfCredits(), + }; + const micChannel = new AudioWsStream('ch_1', this.micStream, language, onTranscript, options); + const loopbackChannel = new AudioWsStream( + 'ch_0', + this.loopbackStream, + language, + onTranscript, + options + ); this.micChannel = micChannel; this.channels = [micChannel, loopbackChannel]; await Promise.all(this.channels.map((channel) => channel.start())); + this.running = true; } /** @@ -649,6 +769,7 @@ class LiveTranscriptionService { } async stop(): Promise { + this.running = false; await Promise.all(this.channels.map((channel) => channel.stop())); this.channels = []; this.micChannel = null; diff --git a/src/renderer/services/mock-transcription.service.ts b/src/renderer/services/mock-transcription.service.ts index b714806b..5d9c3a8b 100644 --- a/src/renderer/services/mock-transcription.service.ts +++ b/src/renderer/services/mock-transcription.service.ts @@ -5,7 +5,6 @@ import { AudioWsStream, MOCK_STREAM_CHANNELS, resolveMicDeviceId, - StreamMetering, } from './live-transcription.service'; /** @@ -27,6 +26,13 @@ import { class MockTranscriptionService { private micStream: MediaStream | null = null; private channel: AudioWsStream | null = null; + private outOfCreditsListeners = new Set<() => void>(); + + /** Subscribe to the running session being closed for credits. Returns the unsubscribe. */ + onOutOfCredits(listener: () => void): () => void { + this.outOfCreditsListeners.add(listener); + return () => this.outOfCreditsListeners.delete(listener); + } async start( audioInputDeviceName: string, @@ -55,22 +61,15 @@ class MockTranscriptionService { }; try { - // Both billing parameters, and both are load-bearing against a different deployment. - // - // `metered=0` is the one that matters against a current backend: a mock interview is - // charged per question, follow-up and report, so charging its socket by the minute as well - // would bill one session twice. `MOCK_STREAM_CHANNELS` is what keeps the older behaviour - // right against a backend that predates that - it ignores `metered` and meters the socket, - // and without `channels=1` it would meter it at half rate, since the default of two - // assumes the live session's pair of sockets. - this.channel = new AudioWsStream( - 'ch_1', - this.micStream, - language, - onTranscript, - MOCK_STREAM_CHANNELS, - StreamMetering.Unmetered - ); + // A mock interview is billed by the minute on this socket, at the same rate as a live one. + // `channels=1` is what makes that true: the default of two assumes the live session's pair + // of sockets, and would bill this single one at half rate. + this.channel = new AudioWsStream('ch_1', this.micStream, language, onTranscript, { + channels: MOCK_STREAM_CHANNELS, + kind: 'mock', + clientSessionId: crypto.randomUUID(), + onOutOfCredits: () => this.outOfCreditsListeners.forEach((listener) => listener()), + }); await this.channel.start(); } catch (error) { // The microphone is already open by this point, and a caller that never saw `start()` diff --git a/src/renderer/types/app-state.ts b/src/renderer/types/app-state.ts index f341ae6a..abfe114c 100644 --- a/src/renderer/types/app-state.ts +++ b/src/renderer/types/app-state.ts @@ -86,16 +86,7 @@ export interface AppState { */ mockInterviewSupported: boolean | null; /** - * What a mock interview costs per unit of work, or `undefined` before the backend has said. - * - * `undefined` means this backend predates per-turn pricing and still meters a mock session by - * the minute, so the client quotes nothing and gates nothing - which is what it did before any - * of this existed. Never read as free: a price of zero and no price at all are different - * answers, and only one of them is a price. - */ - mockPricing?: MockPricing; - /** - * The price of a live interview, in credits per minute, or `undefined` before the backend has + * The price of an interview, live or mock, in credits per minute, or `undefined` before the backend has * said (or before main answers it at all). * * `CREDITS_PER_MINUTE` in `lib/consts.ts` is a fallback mirror of this, used only while this @@ -105,47 +96,3 @@ export interface AppState { */ creditsPerMinute?: number; } - -/** - * Credits per unit of mock-interview work, mirrored from the backend's ping response. - * - * A mock is priced by what it delivers rather than by the clock, because most of a mock session's - * wall time is the product generating a question, speaking it, scoring the turn or writing the - * report - none of which the candidate can act during - and the rest is think-time, which is the - * behaviour the feature exists to train. - */ -export interface MockPricing { - per_question: number; - per_follow_up: number; - per_report: number; -} - -/** - * What a mock interview of `questionCount` questions is guaranteed to cost: every question, and - * the report at the end. - * - * This is the number the start gate reserves, and the promise it makes. Follow-ups are - * deliberately not in it - they are charged as they are delivered and declined by the backend - * when paying for one would eat into this, so quoting the worst case here would refuse a session - * that will almost certainly not cost it. - */ -export function mockSessionPrice(pricing: MockPricing, questionCount: number): number { - return pricing.per_question * questionCount + pricing.per_report; -} - -/** - * The most a session could cost if every question drew the maximum number of follow-ups. - * - * Shown alongside the guaranteed price rather than instead of it, because it is the number the - * candidate is never charged more than - not the one they should expect to pay. - */ -export function mockSessionCeiling( - pricing: MockPricing, - questionCount: number, - maxFollowUpsPerQuestion: number -): number { - return ( - mockSessionPrice(pricing, questionCount) + - pricing.per_follow_up * questionCount * maxFollowUpsPerQuestion - ); -} diff --git a/test/credit-gate.test.mjs b/test/credit-gate.test.mjs new file mode 100644 index 00000000..9f5864ef --- /dev/null +++ b/test/credit-gate.test.mjs @@ -0,0 +1,64 @@ +/** + * The one rule for whether a session may start, run rather than read. + * + * Live and mock are both billed by the minute and closed by the backend at zero, so starting asks + * for a minute's worth. The backend cannot enforce that itself - it cannot tell a new session from + * a reconnect inside one - so this rule is the only thing standing between a candidate and a + * session that is cut off seconds after it starts, and its two edges both fail quietly: a minimum + * off by one refuses a balance that could start, and an unknown balance read as zero greys both + * launch cards out for the first seconds of every launch. + * + * `credit-gate.ts` is renderer code, so it is transpiled here the way `locale-runtime.test.mjs` + * transpiles the locales. Its one import is the compiled-in rate, stubbed beside it, because the + * real `consts.ts` reads `navigator` at load. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import ts from 'typescript'; + +import { createChecker } from './helpers.mjs'; + +const SOURCE = fileURLToPath(new URL('../src/renderer/lib/credit-gate.ts', import.meta.url)); + +export async function run(userDataDir) { + const { check, failures } = createChecker('credit-gate'); + + const outDir = fs.mkdtempSync(path.join(userDataDir, 'credit-gate-')); + try { + const { outputText } = ts.transpileModule(fs.readFileSync(SOURCE, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + fileName: 'credit-gate.ts', + }); + fs.writeFileSync(path.join(outDir, 'consts.mjs'), 'export const CREDITS_PER_MINUTE = 10;\n'); + const out = path.join(outDir, 'credit-gate.mjs'); + fs.writeFileSync(out, outputText.replace("from './consts'", "from './consts.mjs'"), 'utf8'); + const gate = await import(pathToFileURL(out).href); + + check('a minute of credit may start', gate.canStartSession(10, 10)); + check('a credit short of a minute may not', !gate.canStartSession(9, 10)); + check('zero may not', !gate.canStartSession(0, 10)); + check('the minimum follows the served rate', !gate.canStartSession(10, 12)); + check( + 'and falls back to the compiled-in rate before the ping', + !gate.canStartSession(9, undefined) + ); + check( + 'an unknown balance is let through, so the cards are not greyed out at launch', + gate.canStartSession(undefined, 10) + ); + + check('minutes covered rounds down', gate.minutesCovered(25, 10) === 2); + check('and is never negative', gate.minutesCovered(-5, 10) === 0); + check('the minimum is one minute of the served rate', gate.minimumStartCredits(12) === 12); + check( + 'a five-question mock is estimated at about 13 minutes', + gate.mockSessionMinutes(5) === 13 + ); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + + return failures; +} diff --git a/test/mock-billing-contract.test.mjs b/test/mock-billing-contract.test.mjs index 1a948b14..a5d874ac 100644 --- a/test/mock-billing-contract.test.mjs +++ b/test/mock-billing-contract.test.mjs @@ -1,20 +1,16 @@ /** - * The two halves of a mock interview's billing declaration, and why neither may travel alone. + * How a mock interview is billed, and the one parameter that keeps it right. * - * A mock interview is charged per question, follow-up and report rather than by the minute, and - * its ASR socket therefore asks not to be metered. Both facts are things only this side knows, so - * both are things this side says: `billing` on the three charged requests, `metered=0` on the - * socket. The backend is deployed by hand and this client ships on its own schedule, so every - * mixed pairing has to land on exactly one bill. + * Every interview, live or mock, is billed by the minute on its ASR socket at the same rate, and + * nothing else charges. A mock interview used to be priced per question, follow-up and report, + * with `billing=per_turn` on the requests and `metered=0` on the socket; both are gone, and a + * backend still running that scheme reads their absence as "meter this socket by the minute". + * So every pairing of this client with any backend lands on exactly one bill. * - * new client + new backend `billing` honoured, `metered=0` honoured -> per-turn - * new client + old backend both ignored, `channels=1` still sent -> the old per-minute meter - * old client + new backend no `billing`, no `metered` -> the old per-minute meter - * - * What must never happen is a session billed twice, and there are exactly two ways to cause it: - * send `billing` without `metered=0` (a current backend charges per turn *and* runs the clock), - * or drop `channels=1` now that `metered=0` exists (an older backend ignores `metered`, falls - * back to its default of two channels, and halves the bill of every mock interview). + * What must still never happen is a mock billed at half rate, and there is one way to cause it: + * drop `channels=1`. The backend divides the per-minute price by the number of sockets a session + * holds, defaulting to the live session's two, so a mock socket that does not say it is alone + * pays half. * * Source-level, like `mock-tts-playback.test.mjs`: this is renderer and main-process code with no * runtime harness in this directory, and every failure here is a number in a database being wrong @@ -37,45 +33,21 @@ export async function run() { const buildUrl = methodBody(live, 'function buildStreamingUrl('); check('there is a streaming URL builder to read', buildUrl.length > 0); - check('the socket can declare itself unmetered', buildUrl.includes("params.set('metered'")); - check( - 'and only says so when it is not the default, like language and channels', - /if \(metering !== StreamMetering\.Metered\) params\.set\('metered'/.test(buildUrl) - ); - check( - 'the default is metered, so a caller that says nothing is billed as it always was', - /metering: StreamMetering = StreamMetering\.Metered/.test(live) - ); - - // The mock socket sends both, and this is the pairing that a tidy-up breaks: `channels=1` looks - // redundant once `metered=0` exists, and dropping it halves every mock bill on any backend that - // has not been updated yet. - check('the mock socket asks not to be metered', mock.includes('StreamMetering.Unmetered')); - check('and still declares its single channel', mock.includes('MOCK_STREAM_CHANNELS')); + // An installed backend that still honours `metered=0` would bill nothing for the session, + // while the requests no longer carry the per-turn charges that used to pay for it. + check('the socket never asks not to be metered', !buildUrl.includes("'metered'")); + check('the mock socket declares its single channel', mock.includes('MOCK_STREAM_CHANNELS')); // --- the requests ---------------------------------------------------------------------- - check('there is a billing enum with the per-turn value', /PerTurn = 'per_turn'/.test(types)); - check( - 'and the three charged requests share one declaration rather than three copies', - types.includes('interface BilledMockRequest') && - types.includes('GenerateMockQuestionRequest extends BilledMockRequest') && - types.includes('EvaluateMockTurnRequest extends BilledMockRequest') && - types.includes('GenerateMockReportRequest extends BilledMockRequest') - ); - - // Every request that is charged for says how. One that forgets is not an error anywhere: the - // backend reads the absence as an older client and silently charges nothing for that call. - const billingDeclarations = (service.match(/billing: MockBilling\.PerTurn/g) ?? []).length; - check('all three requests declare per-turn billing', billingDeclarations === 3); - - // The follow-up's own guard needs to know what the rest of the session still owes, and the - // backend cannot work it out: `history` counts turns, so it runs ahead of the question number - // wherever a follow-up was asked. - check('the turn request carries what the session still owes', service.includes('remaining_questions:')); + // A per-turn declaration on a request would be honoured by a backend that predates the switch + // to the minute, which would then charge per turn *and* run the clock: the one outcome that + // must not happen. + check('no request declares per-turn billing', !/billing:/.test(service)); + check('there is no billing enum left to send', !/per_turn/.test(types)); check( - 'counted off the question number, which a follow-up does not advance', - /remainingQuestions = Math\.max\([\s\S]{0,200}questionNumber/.test(service) + 'the turn request no longer carries a billing budget', + !service.includes('remaining_questions') ); return failures; diff --git a/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs new file mode 100644 index 00000000..b0e54416 --- /dev/null +++ b/test/out-of-credits.test.mjs @@ -0,0 +1,209 @@ +/** + * What happens when the backend closes an audio socket because the balance ran out. + * + * Every interview is metered by the minute on its ASR socket, and the backend ends that socket + * with close code 4402 when the balance reaches zero - mid-session, or straight after accepting a + * socket opened at zero. Everything that can go wrong here fails quietly: + * + * - a 4402 treated as an ordinary disconnect is retried by the backoff loop forever, against a + * backend that refuses every attempt, while the session sits on screen with no transcription; + * - a 4402 checked after `active` is missed for a socket refused during `start()`, which then + * reports a session that started when it did not; + * - two live channels each reporting it end the session twice - two save prompts, two toasts; + * - a 4402 while a mock is being scored ends a session that is already writing its report. + * + * Source-level, like `language-switch.test.mjs`: renderer code with no runtime harness here. + */ +import { codeOnly, createChecker, methodBody, readSource } from './helpers.mjs'; + +const read = (path) => codeOnly(readSource(new URL(path, import.meta.url))); + +export async function run() { + const { check, failures } = createChecker('out-of-credits'); + + const live = read('../src/renderer/services/live-transcription.service.ts'); + const mock = read('../src/renderer/services/mock-transcription.service.ts'); + const mockHook = read('../src/renderer/hooks/use-mock-interview.ts'); + const controlPanel = read('../src/renderer/components/custom/control-panel/index.tsx'); + + check( + 'the close code mirrors the backend', + live.includes('WS_CLOSE_INSUFFICIENT_CREDITS = 4402') + ); + + // --- no reconnect ---------------------------------------------------------------------- + + const bindHandlers = methodBody(live, 'private bindWebSocketHandlers('); + const onclose = bindHandlers.slice(bindHandlers.indexOf('ws.onclose')); + const creditsCheck = onclose.indexOf('WS_CLOSE_INSUFFICIENT_CREDITS'); + check('the bound close handler looks for the credits code', creditsCheck > 0); + check( + 'before the `active` check, so a socket refused during start() is not missed', + creditsCheck > 0 && creditsCheck < onclose.indexOf('!this.active') + ); + check( + 'and before the reconnect is scheduled', + creditsCheck > 0 && creditsCheck < onclose.indexOf('this.scheduleReconnect()') + ); + + const scheduleReconnect = methodBody(live, 'private scheduleReconnect('); + check( + 'a channel out of credits never schedules a reconnect', + scheduleReconnect.includes('this.outOfCredits') + ); + + const connectWithRetry = methodBody(live, 'private async connectWithRetry('); + check( + 'the retry loop rethrows a credits refusal instead of trying again', + /instanceof OutOfCreditsError[\s\S]{0,200}throw error/.test(connectWithRetry) + ); + check( + 'and reports it, so a reconnect refused before it opened still ends the session', + /instanceof OutOfCreditsError[\s\S]{0,200}this\.handleOutOfCredits\(\)/.test(connectWithRetry) + ); + + const connectWebSocket = methodBody(live, 'private connectWebSocket('); + check( + 'a close that beats the open is still read for its code', + /ws\.onclose[\s\S]{0,200}WS_CLOSE_INSUFFICIENT_CREDITS/.test(connectWebSocket) + ); + + const start = methodBody(live, 'async start() {'); + check( + 'start() throws for a socket refused while it was building the graph', + /if \(this\.outOfCredits\) throw new OutOfCreditsError\(\)/.test(start) + ); + + const handle = methodBody(live, 'private handleOutOfCredits('); + check('a channel reports once', /if \(this\.outOfCredits\) return;/.test(handle)); + check( + 'and only for a running session', + handle.includes('if (this.active) this.onOutOfCredits?.()') + ); + + // --- one stop per session ------------------------------------------------------------------ + + const report = methodBody(live, 'private reportOutOfCredits('); + check( + 'two live channels end the session once', + /this\.outOfCreditsReported\) return;/.test(report) && + report.includes('this.outOfCreditsReported = true;') + ); + check( + 'and the guard is reset for each new session', + live.includes('this.outOfCreditsReported = false;') + ); + + const endForCredits = methodBody(controlPanel, 'const endForCredits = useCallback('); + check( + 'out of stealth the live console ends the session through the Stop path', + /\} else \{\s*void endLiveSessionRef\.current\(\)/.test(endForCredits) + ); + check( + 'the subscription ends the session through that one path', + /liveTranscriptionService\.onOutOfCredits\([\s\S]{0,300}endForCredits\(\)/.test(controlPanel) + ); + + // `startAssistant` writes Running a few seconds after the sockets are up, so a stop inside that + // window would be overwritten and leave a console showing a session with nothing behind it. + check( + 'a report during Starting is held rather than acted on', + /runningStateRef\.current === RunningState\.Starting\)\s*\{\s*outOfCreditsPending\.current = true;/.test( + controlPanel + ) + ); + check( + 'and is acted on once Running, or dropped if the start failed', + /runningState === RunningState\.Running\)\s*\{\s*outOfCreditsPending\.current = false;\s*endForCredits\(\)/.test( + controlPanel + ) && /RunningState\.Idle\)\s*\{\s*outOfCreditsPending\.current = false;/.test(controlPanel) + ); + + const languageHook = read('../src/renderer/hooks/use-interview-language.ts'); + check( + 'a language switch refused for credits does not warn about a half-applied language', + /if \(e instanceof OutOfCreditsError\) return;/.test(languageHook) + ); + + check( + 'a channel refused while the other is still starting does not also stop the session', + /if \(!this\.running \|\| this\.outOfCreditsReported\) return;/.test(report) && + /channel\.start\(\)\)\);\s*this\.running = true;/.test(live) + ); + check( + 'in stealth it stops like the hotkey, with no save prompt over a screen share', + /if \(isStealthRef\.current\) \{[\s\S]{0,200}stopAssistantRef\.current\(\)/.test(controlPanel) + ); + + // --- the mock ------------------------------------------------------------------------------- + + check('the mock socket reports it', mock.includes('onOutOfCredits')); + const mockSubscription = mockHook.slice( + mockHook.indexOf('mockTranscriptionService.onOutOfCredits') + ); + check( + 'a mock being scored is left alone, so its report is not discarded', + /MockInterviewState\.Scoring[\s\S]{0,300}return;/.test(mockSubscription) + ); + check( + 'otherwise it ends the way End does, keeping the answer in progress', + mockSubscription.includes('mockInterview.endSession()') + ); + + // The mock socket opens before main is asked to start, so a refusal can land while the session + // still reads Idle. Ignoring it ran a whole session on a socket that never reconnects. + check( + 'a mock refusal before the session is active is held, not ignored', + /MockInterviewState\.Idle\)\s*\{\s*if \(!captureRunningRef\.current\) return;[\s\S]{0,120}else outOfCreditsPending\.current = true;/.test( + mockHook + ) + ); + // Main's start resolves over one IPC message and the running state arrives over another, so + // Idle can still be read for a moment after the pending check has already run. + check( + 'and once main has started, a refusal still reading Idle is acted on at once', + /if \(mainStarted\.current\) endForCredits\(\);/.test(mockHook) && + /mainStarted\.current = true;\s*if \(outOfCreditsPending\.current\)/.test(mockHook) + ); + check( + 'and acted on once main has started the session', + /await electron\.mockInterview\.start\(setup\);[\s\S]{0,300}if \(outOfCreditsPending\.current\) \{\s*outOfCreditsPending\.current = false;\s*endForCredits\(\)/.test( + mockHook + ) + ); + + // --- the warnings before it ----------------------------------------------------------- + + const warning = read('../src/renderer/hooks/use-low-balance-warning.ts'); + const mockPage = read('../src/renderer/pages/mock-interview/index.tsx'); + check('there is a warning at five minutes and at one', warning.includes('[5, 1] as const')); + check('each fires once per session', /warned\.current\.has\(lowest\)/.test(warning)); + check( + 'and they re-arm when a session ends', + /if \(!active\) \{\s*warned\.current\.clear\(\)/.test(warning) + ); + check( + 'the live console warns while running', + controlPanel.includes('useLowBalanceWarning(runningState === RunningState.Running)') + ); + check( + 'the mock page warns while the session runs, but not over the report being scored', + /useLowBalanceWarning\([\s\S]{0,200}MockInterviewState\.Scoring/.test(mockPage) + ); + + // --- the ledger tags --------------------------------------------------------------------- + + const buildUrl = methodBody(live, 'function buildStreamingUrl('); + check('a mock socket says it is a mock', buildUrl.includes("params.set('kind', kind)")); + check( + 'only when it is not live, whose absence is the default', + buildUrl.includes("kind !== 'live'") + ); + check('every socket carries the client session id', buildUrl.includes("'client_session_id'")); + check( + 'and both live channels share one id', + /const options = \{[\s\S]{0,200}clientSessionId: crypto\.randomUUID\(\)/.test(live) + ); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 4b66aee1..31ab77fd 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -73,9 +73,11 @@ for (const module of [ './mock-transcription-isolation.test.mjs', './mock-transcript-turns.test.mjs', './mock-tts-playback.test.mjs', - // Source-level and independent of the session-driving files above: it reads the client's two - // billing declarations off disk rather than running anything. + // Source-level and independent of the session-driving files above: it reads the client's + // billing parameters off disk rather than running anything. './mock-billing-contract.test.mjs', + './credit-gate.test.mjs', + './out-of-credits.test.mjs', './mock-session-scroll.test.mjs', './speech-chunks.test.mjs', './audio-device-switch.test.mjs',