From 5d8211067d5650a6fde1bd674c722f72ed62b07e Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 09:56:01 -0500 Subject: [PATCH 01/10] feat(billing): bill mock interviews by the minute Drop per-turn mock pricing: MockBilling and billing=per_turn on the three mock requests, remaining_questions, metered=0 on the mock socket, mockPricing on the ping and the price quote in the setup form. Live and mock now share one start rule (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 offer Buy credits. The setup form shows an estimate in minutes and credits, how far the balance goes, and that a session ends with its report when credits run out. Refs #137 Co-Authored-By: Claude Opus 5.5 --- src/main/ipc/app-state.ts | 2 +- src/main/services/health-check.service.ts | 11 +-- src/main/services/mock-interview.service.ts | 21 ------ src/main/types/app-state.ts | 13 +--- src/main/types/health-check.ts | 21 +----- src/main/types/mock-interview.ts | 37 +--------- .../components/custom/control-panel/index.tsx | 14 ++++ .../custom/mock-interview-setup-fields.tsx | 44 +++++------- src/renderer/hooks/use-app-state.tsx | 7 -- .../hooks/use-mock-interview-setup-form.ts | 51 +++----------- src/renderer/i18n/locales/en.ts | 33 ++++----- src/renderer/i18n/locales/ru.ts | 28 ++++---- src/renderer/lib/consts.ts | 10 --- src/renderer/lib/credit-gate.ts | 53 ++++++++++++++ src/renderer/pages/home/index.tsx | 44 +++++------- .../services/live-transcription.service.ts | 33 +-------- .../services/mock-transcription.service.ts | 15 ++-- src/renderer/types/app-state.ts | 55 +-------------- test/credit-gate.test.mjs | 64 +++++++++++++++++ test/mock-billing-contract.test.mjs | 70 ++++++------------- test/run.mjs | 1 + 21 files changed, 250 insertions(+), 377 deletions(-) create mode 100644 src/renderer/lib/credit-gate.ts create mode 100644 test/credit-gate.test.mjs 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..5a724c61 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, @@ -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; @@ -614,16 +609,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 +618,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 +771,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..e114bb05 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -11,6 +11,7 @@ import useIsStealthMode from '@/hooks/use-is-stealth-mode'; 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 { RunningState } from '@/types/app-state'; @@ -110,6 +111,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-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/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 0d811ee6..35737901 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,13 @@ 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', + 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..e2b7e0a5 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,12 @@ 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: 'Недостаточно кредитов для начала', + 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/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index 73e68aff..9f68f507 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -28,25 +28,6 @@ export const LIVE_STREAM_CHANNELS = 2; /** A mock interview captures the microphone only - see `mock-transcription.service.ts`. */ export const MOCK_STREAM_CHANNELS = 1; -/** - * Whether this socket should be charged by the minute. - * - * 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. - */ -export const enum StreamMetering { - Metered = 1, - Unmetered = 0, -} - /** * The streaming URL for one channel. * @@ -60,18 +41,11 @@ export const enum StreamMetering { * backend that predates the parameter ignores an unknown query parameter and bills exactly what * 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. */ -function buildStreamingUrl(language: Language, channels: number, metering: StreamMetering): string { +function buildStreamingUrl(language: Language, channels: number): 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)); const query = params.toString(); return query ? `${STREAMING_URL}?${query}` : STREAMING_URL; } @@ -160,8 +134,7 @@ export class AudioWsStream { * 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 + private readonly channels: number = LIVE_STREAM_CHANNELS ) {} async start() { @@ -390,7 +363,7 @@ 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.ws = ws; let settled = false; diff --git a/src/renderer/services/mock-transcription.service.ts b/src/renderer/services/mock-transcription.service.ts index b714806b..f34c0270 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'; /** @@ -55,21 +54,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. + // 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, - MOCK_STREAM_CHANNELS, - StreamMetering.Unmetered + MOCK_STREAM_CHANNELS ); await this.channel.start(); } catch (error) { 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/run.mjs b/test/run.mjs index 4b66aee1..6b646de2 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -76,6 +76,7 @@ for (const module of [ // Source-level and independent of the session-driving files above: it reads the client's two // billing declarations off disk rather than running anything. './mock-billing-contract.test.mjs', + './credit-gate.test.mjs', './mock-session-scroll.test.mjs', './speech-chunks.test.mjs', './audio-device-switch.test.mjs', From 7c164b1d5dbd7c15af4afa39f00ed6b248706b10 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:01:38 -0500 Subject: [PATCH 02/10] feat(asr): end sessions when credits run out The backend closes an ASR socket with 4402 when the balance reaches zero, and refuses one opened at zero. AudioWsStream never reconnects on that code (in the bound handler, before open, and in the retry loop), and start() throws OutOfCreditsError for a socket refused while it was starting. - Live: one out-of-credits event per session (both channels close), which the control panel turns into the normal Stop path, so the transcript can still be saved, plus a Buy credits toast. - Mock: ends through endSession(), keeping the answer in progress and going on to the report. Ignored once scoring has begun. - Sockets carry kind=mock and a per-session client_session_id so the backend ledger can group one interview's sockets. Refs #137 Co-Authored-By: Claude Opus 5.5 --- .../components/custom/control-panel/index.tsx | 24 ++- src/renderer/hooks/use-mock-interview.ts | 37 +++- src/renderer/i18n/locales/en.ts | 2 + src/renderer/i18n/locales/ru.ts | 2 + .../services/live-transcription.service.ts | 164 ++++++++++++++++-- .../services/mock-transcription.service.ts | 20 ++- test/out-of-credits.test.mjs | 133 ++++++++++++++ test/run.mjs | 5 +- 8 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 test/out-of-credits.test.mjs diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index e114bb05..4436013c 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 { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -13,6 +13,7 @@ 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'; @@ -39,6 +40,27 @@ export default function ControlPanel() { const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); + // The backend closed the live sockets because the balance ran out. Ended the way Stop ends it, + // so the candidate is offered the transcript before it goes. 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 a ref so the subscription is made once, not on every + // render that hands `useEndLiveSession` a new closure. + const endLiveSessionRef = useRef(endLiveSession); + useLayoutEffect(() => { + endLiveSessionRef.current = endLiveSession; + }); + useEffect( + () => + liveTranscriptionService.onOutOfCredits(() => { + toast.error(t.creditGate.outOfCredits, { + description: t.creditGate.outOfCreditsHint, + action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, + }); + void endLiveSessionRef.current(); + }), + [navigate, t] + ); + // 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. // diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index 94917e20..d55c4e1b 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 { 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'; @@ -78,6 +81,38 @@ export function useMockInterview() { // 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. + // 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. + const navigate = useNavigate(); + const stateRef = useRef(null); + useLayoutEffect(() => { + stateRef.current = session?.state ?? null; + }); + useEffect( + () => + mockTranscriptionService.onOutOfCredits(() => { + const state = stateRef.current; + if ( + state === null || + state === MockInterviewState.Idle || + state === MockInterviewState.Scoring || + state === MockInterviewState.Finished || + state === MockInterviewState.Stopping + ) { + return; + } + 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(() => { return () => { if (captureRunningRef.current) { diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index 35737901..f8f03fe6 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1134,6 +1134,8 @@ export const en = { /** 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.', 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 e2b7e0a5..375f51d2 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1140,6 +1140,8 @@ export const ru: Translation = { creditGate: { tooLow: 'Недостаточно кредитов для начала', + outOfCredits: 'Кредиты закончились - сессия завершена', + outOfCreditsHint: 'Пополните баланс, чтобы начать новую.', tooLowHint: (minimum: number, credits: number) => `Нужно хотя бы ${minimum} ${plural(minimum, 'кредит', 'кредита', 'кредитов')} (1 минута), а у вас ${credits}.`, buyCredits: 'Купить кредиты', diff --git a/src/renderer/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index 9f68f507..d3bfb375 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -28,6 +28,46 @@ export const LIVE_STREAM_CHANNELS = 2; /** A mock interview captures the microphone only - see `mock-transcription.service.ts`. */ export const MOCK_STREAM_CHANNELS = 1; +/** + * 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`. + * + * 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. */ +export class OutOfCreditsError extends Error { + constructor() { + super(currentTranslation().creditGate.outOfCredits); + 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; +} + /** * The streaming URL for one channel. * @@ -41,11 +81,21 @@ export const MOCK_STREAM_CHANNELS = 1; * backend that predates the parameter ignores an unknown query parameter and bills exactly what * it billed before, so sending it costs nothing against an older deployment and is the whole fix * against a current one. + * + * `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): 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 (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; } @@ -120,6 +170,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, @@ -129,16 +188,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 - ) {} + 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(); @@ -180,6 +240,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; } @@ -331,6 +397,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. @@ -341,6 +408,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), @@ -363,7 +437,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)); + const ws = new WebSocket( + buildStreamingUrl(this.language, this.channels, this.kind, this.clientSessionId) + ); this.ws = ws; let settled = false; @@ -405,6 +481,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()); + }; }); } @@ -427,13 +513,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 @@ -444,8 +541,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; @@ -507,6 +614,23 @@ 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; + + /** 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.outOfCreditsReported) return; + this.outOfCreditsReported = true; + this.outOfCreditsListeners.forEach((listener) => listener()); + } + async start( audioInputDeviceName: string, sessionToken: string, @@ -552,8 +676,20 @@ 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; + 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())); diff --git a/src/renderer/services/mock-transcription.service.ts b/src/renderer/services/mock-transcription.service.ts index f34c0270..5d9c3a8b 100644 --- a/src/renderer/services/mock-transcription.service.ts +++ b/src/renderer/services/mock-transcription.service.ts @@ -26,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, @@ -57,13 +64,12 @@ class MockTranscriptionService { // 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, - MOCK_STREAM_CHANNELS - ); + 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/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs new file mode 100644 index 00000000..f718d7df --- /dev/null +++ b/test/out-of-credits.test.mjs @@ -0,0 +1,133 @@ +/** + * 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', + /if \(this\.outOfCreditsReported\) return;/.test(report) + ); + check( + 'and the guard is reset for each new session', + live.includes('this.outOfCreditsReported = false;') + ); + + check( + 'the live console ends the session through the Stop path', + /liveTranscriptionService\.onOutOfCredits\([\s\S]{0,400}endLiveSessionRef\.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 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 6b646de2..31ab77fd 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -73,10 +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', From 62a8d15f3ec400760c66764ccea37a215e876000 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:03:37 -0500 Subject: [PATCH 03/10] feat(credits): warn at five minutes and one minute left A running live or mock session gets one toast when the balance falls to five minutes and one at one minute, read off the ping balance. A session that starts below a threshold gets only the lowest one it is under. Not shown while a mock is being scored. Refs #137 Co-Authored-By: Claude Opus 5.5 --- .../components/custom/control-panel/index.tsx | 3 + src/renderer/hooks/use-low-balance-warning.ts | 56 +++++++++++++++++++ src/renderer/i18n/locales/en.ts | 3 + src/renderer/i18n/locales/ru.ts | 3 + src/renderer/pages/mock-interview/index.tsx | 7 +++ test/out-of-credits.test.mjs | 19 +++++++ 6 files changed, 91 insertions(+) create mode 100644 src/renderer/hooks/use-low-balance-warning.ts diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 4436013c..c0402633 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -8,6 +8,7 @@ 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'; @@ -45,6 +46,8 @@ export default function ControlPanel() { // page because this is where `endLiveSession` lives, and these hooks still run in stealth mode - // only the render is skipped. Read through a ref so the subscription is made once, not on every // render that hands `useEndLiveSession` a new closure. + useLowBalanceWarning(runningState === RunningState.Running); + const endLiveSessionRef = useRef(endLiveSession); useLayoutEffect(() => { endLiveSessionRef.current = endLiveSession; 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/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index f8f03fe6..afa843f1 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -1136,6 +1136,9 @@ export const en = { 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 375f51d2..1f88c036 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -1142,6 +1142,9 @@ export const ru: Translation = { 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/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/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs index f718d7df..60bebe83 100644 --- a/test/out-of-credits.test.mjs +++ b/test/out-of-credits.test.mjs @@ -115,6 +115,25 @@ export async function run() { mockSubscription.includes('mockInterview.endSession()') ); + // --- 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('); From 1c1b6d735f52aa484db9dfd665cd61b1d8c7a0d3 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:04:01 -0500 Subject: [PATCH 04/10] docs(claude): describe per-minute billing and the stop at zero Refs #137 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 83 ++++++++++++++++++++++++------------------------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ea2e338..0a42d398 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -665,53 +665,42 @@ 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 ends through +`useEndLiveSession`, so the transcript can still be saved; 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. `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 From b38cac91d4e528aeaa4d0b599bffb79a478728b2 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:04:16 -0500 Subject: [PATCH 05/10] docs(claude): drop a stale per-question billing reference Refs #137 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a42d398..02548179 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 From 1a44668338d278cec00d4c023c41900220630e34 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:32:11 -0500 Subject: [PATCH 06/10] fix(asr): stop quietly in stealth when credits run out Out of stealth, running out of credits ends through the Stop path with the save prompt. In stealth a screen share is likely live, so it now stops the way the stop hotkey does: no modal, no jump to the dashboard, and the next Start still asks about the transcript. The live service also reports out-of-credits only once both channels have started. A channel refused while the other is still starting is start() rejecting, and reporting it too ran a stop beside the start path's own teardown. Refs #137 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++++-- .../components/custom/control-panel/index.tsx | 28 ++++++++++++++----- .../services/live-transcription.service.ts | 10 ++++++- test/out-of-credits.test.mjs | 17 +++++++++-- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02548179..b4cf98a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -692,9 +692,11 @@ loop would retry it until the user stopped the session by hand. The code is chec 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 ends through -`useEndLiveSession`, so the transcript can still be saved; the subscription is in the control -panel because its hooks run in stealth mode too. Mock ends through `endSession()`, keeping the +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. 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. `test/out-of-credits.test.mjs` pins all of it. diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index c0402633..281ee70e 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -31,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(); @@ -41,16 +41,24 @@ export default function ControlPanel() { const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); - // The backend closed the live sockets because the balance ran out. Ended the way Stop ends it, - // so the candidate is offered the transcript before it goes. 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 a ref so the subscription is made once, not on every - // render that hands `useEndLiveSession` a new closure. 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. const endLiveSessionRef = useRef(endLiveSession); + const stopAssistantRef = useRef(stopAssistant); + const isStealthRef = useRef(isStealth); useLayoutEffect(() => { endLiveSessionRef.current = endLiveSession; + stopAssistantRef.current = stopAssistant; + isStealthRef.current = isStealth; }); useEffect( () => @@ -59,7 +67,13 @@ export default function ControlPanel() { description: t.creditGate.outOfCreditsHint, action: { label: t.creditGate.buyCredits, onClick: () => navigate('/payment') }, }); - void endLiveSessionRef.current(); + 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] ); diff --git a/src/renderer/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index d3bfb375..c2fddfa7 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -619,6 +619,11 @@ class LiveTranscriptionService { 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); @@ -626,7 +631,7 @@ class LiveTranscriptionService { } private reportOutOfCredits(): void { - if (this.outOfCreditsReported) return; + if (!this.running || this.outOfCreditsReported) return; this.outOfCreditsReported = true; this.outOfCreditsListeners.forEach((listener) => listener()); } @@ -677,6 +682,7 @@ class LiveTranscriptionService { }; this.outOfCreditsReported = false; + this.running = false; const options = { kind: 'live' as const, clientSessionId: crypto.randomUUID(), @@ -693,6 +699,7 @@ class LiveTranscriptionService { this.micChannel = micChannel; this.channels = [micChannel, loopbackChannel]; await Promise.all(this.channels.map((channel) => channel.start())); + this.running = true; } /** @@ -758,6 +765,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/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs index 60bebe83..ef93636a 100644 --- a/test/out-of-credits.test.mjs +++ b/test/out-of-credits.test.mjs @@ -86,7 +86,8 @@ export async function run() { const report = methodBody(live, 'private reportOutOfCredits('); check( 'two live channels end the session once', - /if \(this\.outOfCreditsReported\) return;/.test(report) + /this\.outOfCreditsReported\) return;/.test(report) && + report.includes('this.outOfCreditsReported = true;') ); check( 'and the guard is reset for each new session', @@ -94,12 +95,22 @@ export async function run() { ); check( - 'the live console ends the session through the Stop path', - /liveTranscriptionService\.onOutOfCredits\([\s\S]{0,400}endLiveSessionRef\.current\(\)/.test( + 'out of stealth the live console ends the session through the Stop path', + /liveTranscriptionService\.onOutOfCredits\([\s\S]{0,800}\} else \{\s*void endLiveSessionRef\.current\(\)/.test( controlPanel ) ); + 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')); From 2161d73990bc1e2da529d2331076a8b05d452dff Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:39:31 -0500 Subject: [PATCH 07/10] fix(asr): hold an out-of-credits stop until the session is running startAssistant writes Running a few seconds after the sockets are up. A 4402 inside that window stopped the session and was then overwritten by the start path writing Running, leaving a console that showed a live session with no sockets behind it. A report during Starting is now held and acted on once Running, or dropped if the start fails. Also: a language switch refused for credits no longer warns that the language only half applied (the session is ending), and a start refused for credits says "not enough credits to start" instead of "the session has ended". Refs #137 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- .../components/custom/control-panel/index.tsx | 52 ++++++++++++++----- src/renderer/hooks/use-interview-language.ts | 5 +- .../services/live-transcription.service.ts | 8 ++- test/out-of-credits.test.mjs | 26 +++++++++- 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4cf98a2..8d9f816c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -693,7 +693,9 @@ close handler *before* `active` (a socket refused during `start()` closes before 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. Out of stealth it ends through +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 diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 281ee70e..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, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -52,32 +52,60 @@ export default function ControlPanel() { // 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(() => { - 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(); + if (runningStateRef.current === RunningState.Starting) { + outOfCreditsPending.current = true; + return; } + endForCredits(); }), - [navigate, t] + [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. // 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/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index c2fddfa7..5d50c9fb 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -40,10 +40,14 @@ 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. */ +/** + * 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 class OutOfCreditsError extends Error { constructor() { - super(currentTranslation().creditGate.outOfCredits); + super(currentTranslation().creditGate.tooLow); this.name = 'OutOfCreditsError'; } } diff --git a/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs index ef93636a..cc7e24e1 100644 --- a/test/out-of-credits.test.mjs +++ b/test/out-of-credits.test.mjs @@ -94,12 +94,36 @@ export async function run() { 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', - /liveTranscriptionService\.onOutOfCredits\([\s\S]{0,800}\} else \{\s*void endLiveSessionRef\.current\(\)/.test( + /\} 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', From 049bc30d808171e91c447fc677b41eddaebf308a Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:42:39 -0500 Subject: [PATCH 08/10] fix(mock): end a session refused for credits before it started The mock socket opens before main is asked to start, so a 4402 could land while the session still read Idle, and the listener ignored it. The session then ran on a socket that never reconnects: questions generated, no transcription. A refusal in that window is now held and acted on once main's start resolves. Also puts the unmount-cleanup comment back above the effect it describes. Refs #137 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++- src/renderer/hooks/use-mock-interview.ts | 45 +++++++++++++++++------- test/out-of-credits.test.mjs | 15 ++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d9f816c..681b65eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -699,7 +699,9 @@ seconds after the sockets are up, and a stop inside that window was overwritten `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. `test/out-of-credits.test.mjs` pins all +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 diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index d55c4e1b..f477d7ef 100644 --- a/src/renderer/hooks/use-mock-interview.ts +++ b/src/renderer/hooks/use-mock-interview.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -78,41 +78,53 @@ export function useMockInterview() { } }, [session?.state, session?.currentQuestion]); - // 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. // 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. const navigate = useNavigate(); const stateRef = useRef(null); + const outOfCreditsPending = 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) outOfCreditsPending.current = true; + return; + } if ( - state === null || - state === MockInterviewState.Idle || state === MockInterviewState.Scoring || state === MockInterviewState.Finished || state === MockInterviewState.Stopping ) { return; } - 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(); + endForCredits(); }), - [navigate] + [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. useEffect(() => { return () => { if (captureRunningRef.current) { @@ -131,6 +143,7 @@ export function useMockInterview() { const electron = window.electronAPI; if (!electron) throw new Error('Electron API not available'); + outOfCreditsPending.current = false; await mockTranscriptionService.start( config?.audioInputDeviceName ?? '', config?.sessionToken ?? '', @@ -143,9 +156,15 @@ export function useMockInterview() { await electron.mockInterview.start(setup); } catch (error) { captureRunningRef.current = false; + outOfCreditsPending.current = false; await mockTranscriptionService.stop(); throw error; } + + if (outOfCreditsPending.current) { + outOfCreditsPending.current = false; + endForCredits(); + } }; const retryScoring = async (): Promise => { diff --git a/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs index cc7e24e1..66d4604e 100644 --- a/test/out-of-credits.test.mjs +++ b/test/out-of-credits.test.mjs @@ -150,6 +150,21 @@ export async function run() { 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\) outOfCreditsPending\.current = true;/.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'); From 403257f89f8296f531c2d6e1ac06c50b078fc348 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:45:34 -0500 Subject: [PATCH 09/10] fix(mock): act on a credits refusal that lands just after start Main's start resolves over one IPC message and the state saying the session is running arrives over another, so this side can still read Idle for a moment after start() returns. A 4402 in that gap was parked as pending after the pending check had already run, and dropped. Once main has started, a refusal reading Idle is now acted on at once. Refs #137 Co-Authored-By: Claude Opus 5.5 --- src/renderer/hooks/use-mock-interview.ts | 12 ++++++++++-- test/out-of-credits.test.mjs | 9 ++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/renderer/hooks/use-mock-interview.ts b/src/renderer/hooks/use-mock-interview.ts index f477d7ef..8b8eb17f 100644 --- a/src/renderer/hooks/use-mock-interview.ts +++ b/src/renderer/hooks/use-mock-interview.ts @@ -85,10 +85,14 @@ export function useMockInterview() { // // 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. + // 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; }); @@ -107,7 +111,9 @@ export function useMockInterview() { mockTranscriptionService.onOutOfCredits(() => { const state = stateRef.current; if (state === null || state === MockInterviewState.Idle) { - if (captureRunningRef.current) outOfCreditsPending.current = true; + if (!captureRunningRef.current) return; + if (mainStarted.current) endForCredits(); + else outOfCreditsPending.current = true; return; } if ( @@ -144,6 +150,7 @@ export function useMockInterview() { if (!electron) throw new Error('Electron API not available'); outOfCreditsPending.current = false; + mainStarted.current = false; await mockTranscriptionService.start( config?.audioInputDeviceName ?? '', config?.sessionToken ?? '', @@ -161,6 +168,7 @@ export function useMockInterview() { throw error; } + mainStarted.current = true; if (outOfCreditsPending.current) { outOfCreditsPending.current = false; endForCredits(); diff --git a/test/out-of-credits.test.mjs b/test/out-of-credits.test.mjs index 66d4604e..b0e54416 100644 --- a/test/out-of-credits.test.mjs +++ b/test/out-of-credits.test.mjs @@ -154,10 +154,17 @@ export async function run() { // 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\) outOfCreditsPending\.current = true;/.test( + /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( From b8b978fa8069ae2ba2856ce5d28c7757ffcaa030 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 6 Oct 2026 10:56:03 -0500 Subject: [PATCH 10/10] docs(mock): the 402 path is a guard now, not per-question pricing Refs #137 Co-Authored-By: Claude Opus 5.5 --- src/main/services/mock-interview.service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 5a724c61..616961db 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -62,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 { @@ -304,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;