From ad05c62a0c1778e055f28a7540477a25fc5fccd9 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 25 Jun 2026 07:20:48 -0500 Subject: [PATCH 1/9] test: unit coverage for model selection, classify, answer, capture buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of broader test coverage. The DB layer (better-sqlite3) is built for Electron's ABI and can't load under vitest's node env, so these mock the DB/OpenAI boundaries with vi.mock and test the pure logic + edge cases (40 new tests, 20→60): - models: preset resolution + override precedence, isReasoningModel, reasoningParam (only attached to reasoning models), reasoningEffort defaults, Custom-divergence inputs. - questions (classify): defensive defaulting (fails closed on uncertainty), input-text echo, malformed-JSON behavior. - answer: per-length max_output_tokens caps (220/800), prompt assembly (format, type, pronunciation, context tagging, no-context note), streamed delta/usage/meta events. - codingMode: multi-image buffer cap at 8 (drops oldest), clear, and solve-all-then-clear. Also: vitest.config now mirrors the tsconfig path aliases (@shared/@main/@renderer) so modules with value imports from @shared (e.g. EVENTS) are testable. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/services/capture/codingMode.test.ts | 66 +++++++++ src/main/services/openai/answer.test.ts | 114 +++++++++++++++ src/main/services/openai/models.test.ts | 140 +++++++++++++++++++ src/main/services/openai/questions.test.ts | 72 ++++++++++ vitest.config.ts | 14 +- 5 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/main/services/capture/codingMode.test.ts create mode 100644 src/main/services/openai/answer.test.ts create mode 100644 src/main/services/openai/models.test.ts create mode 100644 src/main/services/openai/questions.test.ts diff --git a/src/main/services/capture/codingMode.test.ts b/src/main/services/capture/codingMode.test.ts new file mode 100644 index 0000000..974d56f --- /dev/null +++ b/src/main/services/capture/codingMode.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock everything codingMode touches except its own buffer logic. +vi.mock('electron', () => ({ clipboard: { readText: () => '' } })); +vi.mock('../../ipc/broadcast', () => ({ broadcast: vi.fn() })); +vi.mock('../../windows/overlayWindow', () => ({ showOverlay: vi.fn() })); +vi.mock('../openai/coding', () => ({ + // eslint-disable-next-line require-yield + solveFromOcr: vi.fn(async function* () {}), +})); +vi.mock('../openai/vision', () => ({ + solveFromImages: vi.fn(() => (async function* () {})()), +})); + +import { addCapture, clearCaptures, solveCaptures } from './codingMode'; +import { broadcast } from '../../ipc/broadcast'; +import { solveFromImages } from '../openai/vision'; +import { EVENTS } from '@shared/ipc'; + +const lastBufferImages = (): string[] => { + const calls = (broadcast as unknown as { mock: { calls: unknown[][] } }).mock.calls.filter( + (c) => c[0] === EVENTS.captureBuffer, + ); + return ((calls.at(-1)?.[1] as { images: string[] }) ?? { images: [] }).images; +}; + +beforeEach(() => { + clearCaptures(); + vi.clearAllMocks(); +}); + +describe('multi-image capture buffer', () => { + it('adds a capture and broadcasts the updated buffer to the overlay', () => { + addCapture('img-a'); + expect(broadcast).toHaveBeenCalledWith(EVENTS.captureBuffer, { images: ['img-a'] }, ['overlay']); + }); + + it('caps the buffer at 8, dropping the oldest', () => { + for (let i = 0; i < 9; i++) addCapture(`img-${i}`); + const imgs = lastBufferImages(); + expect(imgs).toHaveLength(8); + expect(imgs[0]).toBe('img-1'); // img-0 (oldest) was dropped + expect(imgs.at(-1)).toBe('img-8'); + }); + + it('clearCaptures empties the buffer and broadcasts []', () => { + addCapture('img-a'); + vi.clearAllMocks(); + clearCaptures(); + expect(lastBufferImages()).toEqual([]); + }); + + it('solveCaptures is a no-op on an empty buffer', async () => { + await solveCaptures(); + expect(solveFromImages).not.toHaveBeenCalled(); + }); + + it('solveCaptures sends ALL buffered images in one call, then clears', async () => { + addCapture('img-1'); + addCapture('img-2'); + await solveCaptures(); + expect(solveFromImages).toHaveBeenCalledTimes(1); + expect(solveFromImages).toHaveBeenCalledWith(['img-1', 'img-2']); + expect(lastBufferImages()).toEqual([]); // buffer cleared after solving + }); +}); diff --git a/src/main/services/openai/answer.test.ts b/src/main/services/openai/answer.test.ts new file mode 100644 index 0000000..9804ed4 --- /dev/null +++ b/src/main/services/openai/answer.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { AnswerEvent } from './answer'; + +// Capture the request body passed to responses.stream, and feed back a fixed +// fake stream (two text deltas + usage). Mock the model resolver so models.ts → +// db → better-sqlite3 is never loaded. +const h = vi.hoisted(() => ({ lastBody: null as Record | null })); +vi.mock('./client', () => ({ + openai: () => ({ + responses: { + stream: (body: Record) => { + h.lastBody = body; + return { + async *[Symbol.asyncIterator]() { + yield { type: 'response.output_text.delta', delta: 'Hello' }; + yield { type: 'response.output_text.delta', delta: ' world' }; + yield { type: 'response.ignored.event' }; // non-delta events are skipped + }, + finalResponse: async () => ({ usage: { input_tokens: 12, output_tokens: 7 } }), + }; + }, + }, + }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-mini' })); + +import { streamAnswer } from './answer'; + +const profile = { targetRole: 'SWE', targetCompany: 'Acme' } as Parameters[0]['profile']; + +function baseInput(over: Partial[0]> = {}) { + return { + question: 'Tell me about a hard bug.', + contextChunks: [{ id: 'c1', sourceType: 'resume' as const, content: 'Fixed a race condition', score: 0.8 }], + profile, + style: 'default' as const, + length: 'key_points' as const, + pronunciation: false, + interviewType: 'behavioral' as const, + ...over, + }; +} + +async function collect(gen: AsyncGenerator): Promise { + const out: AnswerEvent[] = []; + for await (const ev of gen) out.push(ev); + return out; +} + +const userPrompt = () => String((h.lastBody!.input as { role: string; content: string }[])[1].content); + +beforeEach(() => { + h.lastBody = null; +}); + +describe('streamAnswer — request body', () => { + it('caps key_points at 220 output tokens', async () => { + await collect(streamAnswer(baseInput({ length: 'key_points' }))); + expect(h.lastBody!.max_output_tokens).toBe(220); + expect(userPrompt()).toContain('KEY POINTS'); + expect(userPrompt()).toContain('~60 words'); + }); + + it('caps detailed at 800 output tokens', async () => { + await collect(streamAnswer(baseInput({ length: 'detailed' }))); + expect(h.lastBody!.max_output_tokens).toBe(800); + expect(userPrompt()).toContain('DETAILED'); + }); + + it('includes the pronunciation instruction only when enabled', async () => { + await collect(streamAnswer(baseInput({ pronunciation: true }))); + expect(userPrompt()).toMatch(/phonetic respelling/i); + await collect(streamAnswer(baseInput({ pronunciation: false }))); + expect(userPrompt()).not.toMatch(/phonetic respelling/i); + }); + + it('injects the chosen format and interview type', async () => { + await collect(streamAnswer(baseInput({ style: 'star', interviewType: 'coding' }))); + expect(userPrompt()).toContain('STAR'); + expect(userPrompt()).toContain('Interview type: coding'); + }); + + it('embeds retrieved context tagged by source', async () => { + await collect(streamAnswer(baseInput())); + expect(userPrompt()).toContain('(resume) Fixed a race condition'); + }); + + it('notes when there is NO matching context', async () => { + await collect(streamAnswer(baseInput({ contextChunks: [] }))); + expect(userPrompt()).toContain('no relevant profile context found'); + }); +}); + +describe('streamAnswer — streamed events', () => { + it('yields a delta per output_text.delta and skips other events', async () => { + const evs = await collect(streamAnswer(baseInput())); + const tokens = evs.filter((e) => e.type === 'delta').map((e) => (e as { token: string }).token); + expect(tokens).toEqual(['Hello', ' world']); + }); + + it('yields a usage event from finalResponse', async () => { + const evs = await collect(streamAnswer(baseInput())); + expect(evs).toContainEqual({ type: 'usage', prompt: 12, completion: 7 }); + }); + + it('sets a riskWarning in meta only when context is empty', async () => { + const withCtx = (await collect(streamAnswer(baseInput()))).find((e) => e.type === 'meta'); + expect((withCtx as { riskWarning: string | null }).riskWarning).toBeNull(); + const noCtx = (await collect(streamAnswer(baseInput({ contextChunks: [] })))).find( + (e) => e.type === 'meta', + ); + expect((noCtx as { riskWarning: string | null }).riskWarning).toBeTruthy(); + }); +}); diff --git a/src/main/services/openai/models.test.ts b/src/main/services/openai/models.test.ts new file mode 100644 index 0000000..5772f1f --- /dev/null +++ b/src/main/services/openai/models.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the DB-backed settings repo so importing models.ts doesn't pull in +// better-sqlite3 (which is built for Electron's ABI and can't load under node). +// A mutable `state` lets each test drive the "stored" preset/overrides. +const state = vi.hoisted(() => ({ + preset: null as string | null, + models: {} as Record, + efforts: {} as Record, +})); + +vi.mock('../../db/repositories/settings.repo', () => ({ + SETTINGS_KEYS: { modelPreset: 'model_preset', models: 'models', reasoningEfforts: 'reasoning_efforts' }, + settingsRepo: { + get: (k: string) => (k === 'model_preset' ? state.preset : null), + getJson: (k: string, fallback: unknown) => + k === 'models' ? state.models : k === 'reasoning_efforts' ? state.efforts : fallback, + }, +})); + +import { + PRESETS, + defaultModels, + model, + modelPreset, + presetModels, + reasoningEffort, + isReasoningModel, + reasoningParam, +} from './models'; + +beforeEach(() => { + state.preset = null; + state.models = {}; + state.efforts = {}; +}); + +describe('modelPreset()', () => { + it('defaults to balanced when unset', () => { + expect(modelPreset()).toBe('balanced'); + }); + it('returns a valid stored preset', () => { + state.preset = 'low_cost'; + expect(modelPreset()).toBe('low_cost'); + state.preset = 'best'; + expect(modelPreset()).toBe('best'); + }); + it('falls back to balanced for an unknown value', () => { + state.preset = 'turbo'; // not a real preset + expect(modelPreset()).toBe('balanced'); + }); +}); + +describe('presetModels() / PRESETS', () => { + it('every preset defines the same task keys', () => { + const keys = Object.keys(PRESETS.balanced).sort(); + expect(Object.keys(PRESETS.low_cost).sort()).toEqual(keys); + expect(Object.keys(PRESETS.best).sort()).toEqual(keys); + }); + it('keeps the live paths (answer/classify) on NON-reasoning models in every preset', () => { + for (const p of [PRESETS.balanced, PRESETS.low_cost, PRESETS.best]) { + expect(isReasoningModel(p.answer)).toBe(false); + expect(isReasoningModel(p.classify)).toBe(false); + } + }); + it('uses a reasoning model for the coding solver in every preset', () => { + for (const p of [PRESETS.balanced, PRESETS.low_cost, PRESETS.best]) { + expect(isReasoningModel(p.coding)).toBe(true); + } + }); + it('reflects the active preset', () => { + expect(presetModels()).toEqual(PRESETS.balanced); + state.preset = 'best'; + expect(presetModels()).toEqual(PRESETS.best); + }); + it('defaultModels is the balanced table', () => { + expect(defaultModels).toEqual(PRESETS.balanced); + }); +}); + +describe('model() resolution order', () => { + it('uses the active preset when there is no override', () => { + state.preset = 'best'; + expect(model('answer')).toBe(PRESETS.best.answer); + }); + it('a per-task override wins over the preset', () => { + state.preset = 'balanced'; + state.models = { answer: 'gpt-4o' }; + expect(model('answer')).toBe('gpt-4o'); + expect(model('classify')).toBe(PRESETS.balanced.classify); // untouched key still preset + }); + it('ignores an empty-string override (falls back to preset)', () => { + state.models = { coding: '' }; + expect(model('coding')).toBe(PRESETS.balanced.coding); + }); +}); + +describe('isReasoningModel()', () => { + it('flags GPT-5 and o-series', () => { + for (const id of ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'GPT-5-MINI', 'o4-mini', 'o3']) { + expect(isReasoningModel(id)).toBe(true); + } + }); + it('does not flag the gpt-4.1 / gpt-4o / embedding families', () => { + for (const id of ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'text-embedding-3-small']) { + expect(isReasoningModel(id)).toBe(false); + } + }); +}); + +describe('reasoningEffort()', () => { + it('coding defaults to low', () => { + expect(reasoningEffort('coding')).toBe('low'); + }); + it('non-coding tasks have no default effort', () => { + expect(reasoningEffort('answer')).toBeNull(); + }); + it('a stored override wins over the default', () => { + state.efforts = { coding: 'high' }; + expect(reasoningEffort('coding')).toBe('high'); + }); +}); + +describe('reasoningParam()', () => { + it('attaches effort for a reasoning coding model (default gpt-5-mini @ low)', () => { + expect(reasoningParam('coding')).toEqual({ reasoning: { effort: 'low' } }); + }); + it('is EMPTY when the coding model is overridden to a non-reasoning model', () => { + state.models = { coding: 'gpt-4.1' }; + expect(reasoningParam('coding')).toEqual({}); + }); + it('is empty for tasks with no configured effort even on a reasoning model', () => { + state.preset = 'best'; // answer = full gpt-4.1 (non-reasoning) — still empty + expect(reasoningParam('answer')).toEqual({}); + }); + it('respects an effort override on a reasoning model', () => { + state.efforts = { coding: 'high' }; + expect(reasoningParam('coding')).toEqual({ reasoning: { effort: 'high' } }); + }); +}); diff --git a/src/main/services/openai/questions.test.ts b/src/main/services/openai/questions.test.ts new file mode 100644 index 0000000..a7243bb --- /dev/null +++ b/src/main/services/openai/questions.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Stub the OpenAI client (the classifier's model response) and the model resolver +// (so models.ts → db → better-sqlite3 is never loaded). +const h = vi.hoisted(() => ({ output: '{}' })); +vi.mock('./client', () => ({ + openai: () => ({ responses: { create: async () => ({ output_text: h.output }) } }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-nano' })); + +import { classifyQuestion } from './questions'; + +beforeEach(() => { + h.output = '{}'; +}); + +describe('classifyQuestion', () => { + it('maps a well-formed classifier response', async () => { + h.output = JSON.stringify({ + isQuestion: true, + type: 'coding', + confidence: 0.92, + strategy: 'lead with the approach', + }); + const r = await classifyQuestion('reverse a linked list'); + expect(r).toEqual({ + isQuestion: true, + text: 'reverse a linked list', + type: 'coding', + confidence: 0.92, + strategy: 'lead with the approach', + }); + }); + + it('always echoes the input text, never the model output', async () => { + h.output = JSON.stringify({ isQuestion: true, text: 'SOMETHING ELSE' }); + const r = await classifyQuestion('the real utterance'); + expect(r.text).toBe('the real utterance'); + }); + + it('defaults every field when the model returns an empty object', async () => { + h.output = '{}'; + const r = await classifyQuestion('hello'); + expect(r).toEqual({ + isQuestion: false, // safe default: don't answer on uncertainty + text: 'hello', + type: 'behavioral', + confidence: 0, + strategy: '', + }); + }); + + it('treats a missing isQuestion as not-a-question (false), not truthy', async () => { + h.output = JSON.stringify({ type: 'product', confidence: 0.5 }); + const r = await classifyQuestion('mm-hmm'); + expect(r.isQuestion).toBe(false); + }); + + it('keeps confidence 0 when omitted (so the >=0.4 gate fails closed)', async () => { + h.output = JSON.stringify({ isQuestion: true }); + const r = await classifyQuestion('uh'); + expect(r.confidence).toBe(0); + }); + + // Documents a known gap: output_text is parsed without a guard. json_object format + // makes this unlikely, but malformed JSON currently rejects (caught upstream in + // processFinalTranscript). If this is ever hardened, update this test. + it('rejects on malformed JSON (current unguarded behavior)', async () => { + h.output = 'not json at all'; + await expect(classifyQuestion('x')).rejects.toThrow(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index af1fee9..5367ce8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,19 @@ import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; -// Unit tests cover the pure logic (no electron/db/network). See *.test.ts. +const r = (p: string) => resolve(process.cwd(), p); + +// Unit tests cover pure + lightly-mocked logic (electron/db/network are mocked per +// test — see *.test.ts). The path aliases mirror tsconfig so modules that import +// from `@shared`/`@main`/`@renderer` (value imports, e.g. EVENTS) resolve here too. export default defineConfig({ + resolve: { + alias: { + '@shared': r('src/shared'), + '@main': r('src/main'), + '@renderer': r('src/renderer'), + }, + }, test: { environment: 'node', include: ['src/**/*.test.ts'], From 267d9e6d5b42d04405990aa088bea3496c40101a Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 25 Jun 2026 07:26:16 -0500 Subject: [PATCH 2/9] test: parsing defaults + extract & test answer-card reducers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More Phase 1 coverage (60→77 tests): - parsing: resume/JD/company defensive defaulting (empty → safe defaults, partial → fill rest, overview defaults to "" not []), and the 24k input cap sent to the model. - Extract the Cue Card answer-history logic out of Overlay.tsx into answerCards.ts (makeCard/patchLast/addCard/removeCard/toggleCollapsed) so it's unit-testable, and cover the edge cases: history OFF replaces, history ON collapses + keeps prior (answers preserved), patchLast no-ops on empty + doesn't mutate, remove/toggle by id. Overlay.tsx now imports these (behavior unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/services/openai/parsing.test.ts | 88 ++++++++++++++++++++++++ src/renderer/overlay/Overlay.tsx | 47 +++---------- src/renderer/overlay/answerCards.test.ts | 77 +++++++++++++++++++++ src/renderer/overlay/answerCards.ts | 41 +++++++++++ 4 files changed, 217 insertions(+), 36 deletions(-) create mode 100644 src/main/services/openai/parsing.test.ts create mode 100644 src/renderer/overlay/answerCards.test.ts create mode 100644 src/renderer/overlay/answerCards.ts diff --git a/src/main/services/openai/parsing.test.ts b/src/main/services/openai/parsing.test.ts new file mode 100644 index 0000000..da3cc19 --- /dev/null +++ b/src/main/services/openai/parsing.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Capture the request body + drive the model's JSON output; mock the model resolver. +const h = vi.hoisted(() => ({ output: '{}', lastBody: null as Record | null })); +vi.mock('./client', () => ({ + openai: () => ({ + responses: { + create: async (body: Record) => { + h.lastBody = body; + return { output_text: h.output }; + }, + }, + }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-mini' })); + +import { parseResume, parseJobDescription, parseCompany } from './parsing'; + +beforeEach(() => { + h.output = '{}'; + h.lastBody = null; +}); + +describe('parseResume', () => { + it('defaults every field to [] for an empty object', async () => { + h.output = '{}'; + const r = await parseResume('resume text'); + expect(r).toEqual({ + skills: [], + projects: [], + workHistory: [], + metrics: [], + education: [], + certifications: [], + techStack: [], + leadership: [], + }); + }); + + it('keeps present fields and defaults the rest', async () => { + h.output = JSON.stringify({ skills: ['ts', 'go'], metrics: ['+30% perf'] }); + const r = await parseResume('x'); + expect(r.skills).toEqual(['ts', 'go']); + expect(r.metrics).toEqual(['+30% perf']); + expect(r.projects).toEqual([]); // missing → default + }); + + it('caps the input text at 24k chars sent to the model', async () => { + await parseResume('a'.repeat(30_000)); + const userContent = String((h.lastBody!.input as { role: string; content: string }[])[1].content); + expect(userContent.length).toBe(24_000); + }); +}); + +describe('parseJobDescription', () => { + it('defaults all arrays for an empty object', async () => { + const r = await parseJobDescription('jd'); + expect(r).toEqual({ requirements: [], responsibilities: [], keywords: [], focusAreas: [] }); + }); + it('passes through provided arrays', async () => { + h.output = JSON.stringify({ requirements: ['5y exp'], keywords: ['react'] }); + const r = await parseJobDescription('jd'); + expect(r.requirements).toEqual(['5y exp']); + expect(r.keywords).toEqual(['react']); + expect(r.responsibilities).toEqual([]); + }); +}); + +describe('parseCompany', () => { + it('defaults overview to "" (string) and the rest to []', async () => { + const r = await parseCompany('site text'); + expect(r.overview).toBe(''); + expect(r).toMatchObject({ + products: [], + techStack: [], + values: [], + culture: [], + recentNews: [], + interviewAngles: [], + }); + }); + it('keeps a provided overview string', async () => { + h.output = JSON.stringify({ overview: 'We build payments infra.', products: ['API'] }); + const r = await parseCompany('x'); + expect(r.overview).toBe('We build payments infra.'); + expect(r.products).toEqual(['API']); + }); +}); diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index 4f31903..4270be1 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -12,6 +12,14 @@ import type { } from '@shared/types'; import { Markdown } from '../components/Markdown'; import { Modal } from '../components/ui'; +import { + type AnswerCard, + addCard, + makeCard, + patchLast, + removeCard, + toggleCollapsed, +} from './answerCards'; import { BoltIcon, ChevronRightIcon, @@ -35,23 +43,6 @@ interface Line { text: string; } -/** One generated answer (interview question or coding solve). With history on, past - * cards are kept (collapsed) instead of being replaced; each is individually removable. */ -interface AnswerCard { - id: number; - question: string; - answer: string; - meta: AnswerMetaEvent | null; - context: ContextSentEvent | null; - streaming: boolean; - collapsed: boolean; -} - -/** Apply a patch to the newest (current) card. */ -function patchLast(cards: AnswerCard[], patch: Partial): AnswerCard[] { - if (!cards.length) return cards; - return [...cards.slice(0, -1), { ...cards[cards.length - 1], ...patch }]; -} // Cap transcript lines kept in the DOM — a long interview can produce thousands. const MAX_LINES = 300; @@ -160,23 +151,7 @@ export default function Overlay() { const text = (p as { text: string }).text; cancelFlush(); // History on: collapse prior cards and add a fresh one. Off: replace. - setCards((cs) => { - const prior = historyEnabledRef.current - ? cs.map((c) => ({ ...c, collapsed: true, streaming: false })) - : []; - return [ - ...prior, - { - id: cardId.current++, - question: text, - answer: '', - meta: null, - context: null, - streaming: true, - collapsed: false, - }, - ]; - }); + setCards((cs) => addCard(cs, makeCard(cardId.current++, text), historyEnabledRef.current)); // Mirror the dashboard: surface the detected question in the transcript too. setTranscript((t) => [...t, { id: lineId.current++, speaker: 'detected question', text }]); }), @@ -889,7 +864,7 @@ export default function Overlay() { + + )} + {!sessionId && (
diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index 4270be1..d49d0f6 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -153,12 +153,16 @@ export default function Overlay() { // History on: collapse prior cards and add a fresh one. Off: replace. setCards((cs) => addCard(cs, makeCard(cardId.current++, text), historyEnabledRef.current)); // Mirror the dashboard: surface the detected question in the transcript too. - setTranscript((t) => [...t, { id: lineId.current++, speaker: 'detected question', text }]); + setTranscript((t) => + [...t, { id: lineId.current++, speaker: 'detected question', text }].slice(-MAX_LINES * 2), + ); }), api.events.onTranscriptDelta((p) => { const d = p as { text: string; speaker: string; isFinal: boolean }; if (d.isFinal) { - setTranscript((t) => [...t, { id: lineId.current++, speaker: d.speaker, text: d.text }]); + setTranscript((t) => + [...t, { id: lineId.current++, speaker: d.speaker, text: d.text }].slice(-MAX_LINES * 2), + ); setInterim(''); } else { setInterim((s) => s + d.text); @@ -905,7 +909,7 @@ export default function Overlay() { setAskText(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && sendAsk()} + onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && sendAsk()} placeholder="Ask a question…" className="min-w-0 flex-1 rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 text-[11px] text-neutral-100 outline-none focus:border-indigo-500" /> @@ -918,30 +922,6 @@ export default function Overlay() {
)} - {/* Talking points + resume match (expanded mode) */} - {mode === 'expanded' && meta && ( -
- {meta.talkingPoints.length > 0 && ( -
    - {meta.talkingPoints.map((t, i) => ( -
  • {t}
  • - ))} -
- )} - {meta.resumeMatch && ( -

- Resume: - {meta.resumeMatch} -

- )} - {meta.followupQuestion && ( -

- Ask back: - {meta.followupQuestion} -

- )} -
- )} {meta?.riskWarning && (

@@ -1139,7 +1119,12 @@ function Btn(props: { ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:bg-neutral-700/70 hover:text-neutral-200'; return ( - ); diff --git a/src/renderer/store/useLiveSession.ts b/src/renderer/store/useLiveSession.ts index cd6b043..593a60a 100644 --- a/src/renderer/store/useLiveSession.ts +++ b/src/renderer/store/useLiveSession.ts @@ -52,6 +52,9 @@ interface LiveSessionState { ask: (question: string) => Promise; } +// Cap the in-memory transcript so a long session can't grow it without bound. +const MAX_TRANSCRIPT = 500; + // --- audio capture singletons (outside React) --- let ctx: AudioContext | null = null; let node: ScriptProcessorNode | null = null; @@ -84,7 +87,11 @@ export const useLiveSession = create((set, get) => { const d = p as { text: string; speaker: string; isFinal: boolean }; if (d.isFinal) { set((s) => ({ - transcript: [...s.transcript, { id: lineId++, speaker: d.speaker, text: d.text }], + // Cap the backing array — a multi-hour interview would otherwise accumulate + // thousands of line objects in memory (the UI only renders the last ~300). + transcript: [...s.transcript, { id: lineId++, speaker: d.speaker, text: d.text }].slice( + -MAX_TRANSCRIPT, + ), interim: '', })); } else { @@ -94,7 +101,10 @@ export const useLiveSession = create((set, get) => { api.events.onQuestionDetected((p) => { const d = p as { text: string }; set((s) => ({ - transcript: [...s.transcript, { id: lineId++, speaker: 'detected question', text: d.text }], + transcript: [ + ...s.transcript, + { id: lineId++, speaker: 'detected question', text: d.text }, + ].slice(-MAX_TRANSCRIPT), })); }); api.events.onSavePrompt((p) => set({ pendingSave: p })); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9d64b4f..fd1cf88 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -61,9 +61,6 @@ export const IPC = { create: 'notes:create', delete: 'notes:delete', }, - rag: { - search: 'rag:search', - }, session: { start: 'session:start', resume: 'session:resume', From 4a00de6f03594333bb4ecebbc8f2529e2ffb9294 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 25 Jun 2026 12:27:52 -0500 Subject: [PATCH 8/9] release: v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First stable release. Bump package.json 0.9.0 → 1.0.0, add the MIT LICENSE file, and changelog/1.0.0.md (drives in-app "What's New" + APP_VERSION). Theme: reliability — live failures now surface instead of hanging — plus a11y/UX polish and dead-code cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- LICENSE | 21 +++++++++++++++++++++ changelog/1.0.0.md | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 LICENSE create mode 100644 changelog/1.0.0.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e937463 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 tpikachu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/changelog/1.0.0.md b/changelog/1.0.0.md new file mode 100644 index 0000000..1cc7f3c --- /dev/null +++ b/changelog/1.0.0.md @@ -0,0 +1,39 @@ +# 1.0.0 — 2026-06-25 + +The first stable release. This one is about **reliability and polish**: live failures +now surface instead of hanging, dead corners of the UI are gone, and a round of +accessibility and cross-platform fixes round out a credible 1.0. + +## Fixed + +- **Live answers no longer hang silently on failure.** If the connection drops, your + key expires, or you hit a quota mid-interview, the Cue Card now shows a clear error + and stops the "thinking…" spinner — instead of a card stuck loading forever. This + covers both retrieval (embeddings) and answer generation. +- **A dropped transcription connection is now reported.** If the speech-to-text socket + closes unexpectedly, BrainCue tells you to restart the interview — rather than going + silently deaf while the mic keeps running. +- **Cancelling the screen picker / denying the mic** no longer leaves a phantom "live" + session (carried over from 0.9), and coding-solve errors now show a clean message. + +## Changed + +- **Cleaner Cue Card.** Removed the empty "expanded mode" panel (talking points / + resume match) that never populated; the streamed answer, risk warnings, transcript, + and audio meter remain. +- **Higher-fidelity screen capture** for coding problems on HiDPI displays — small code + text now stays legible for the solver. +- **Friendlier errors** in Mock interview (inline messages instead of blocking pop-ups). +- Long interviews keep memory bounded (the transcript no longer grows without limit). + +## Accessibility + +- Cue Card icon buttons are now labeled for screen readers. +- Dialogs move focus into themselves and restore it on close, and the manual **Ask** + box no longer submits mid-composition (CJK/IME input). + +## Under the hood + +- Added a unit + end-to-end test suite (Vitest + Playwright/Electron) and a CI gate + (typecheck · tests · build) on every change. +- Removed an unimplemented internal `rag:search` channel and tidied the docs. diff --git a/package.json b/package.json index 2aff69d..d972dac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-interview-assistant", - "version": "0.9.0", + "version": "1.0.0", "description": "BrainCue Copilot — desktop AI interview copilot (Electron + React + OpenAI). Local-first data, BYO OpenAI key.", "author": "tpikachu", "license": "MIT", From ffbd609d3bb6ad9e4e1c1f9cbc0008a87b504f82 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 25 Jun 2026 12:42:35 -0500 Subject: [PATCH 9/9] fix: address adversarial review of the v1.0 diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent review of the diff caught a real regression + polish items: - BLOCKER: the new Modal focus effect depended on `onClose` (a fresh inline arrow each render), so during a live session the Cue Card's per-frame re-renders (audio meter) re-ran it and yanked focus out of the settings dropdowns — and corrupted the focus-restore target. Now it reads onClose via a ref and depends only on `open`, so focus-in/restore happens once per open/close. - SHOULD: the realtime ws fires 'error' THEN 'close' for the same failure, so the new close→onError clobbered the specific message (e.g. 401) with the generic "disconnected" one. Now close skips it when a specific error already surfaced. - Nits: failed manual ask no longer leaves an unhandled rejection (renderer .catch) and keeps its transcript line; removed stale `rag:search` references in docs/09-MVP-PLAN.md and e2e/README.md; collapsed a leftover double blank line. typecheck · 77 unit · build · 7 e2e all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + docs/09-MVP-PLAN.md | 2 +- e2e/README.md | 3 +-- src/main/services/openai/realtime.ts | 19 +++++++++++++------ src/renderer/components/ui.tsx | 14 ++++++++++---- src/renderer/overlay/Overlay.tsx | 3 +-- src/renderer/store/useLiveSession.ts | 10 ++++++++-- 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 75ec66f..22f568c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ out/ release/ dist/ *.log +*.tsbuildinfo # Playwright e2e output test-results/ diff --git a/docs/09-MVP-PLAN.md b/docs/09-MVP-PLAN.md index ba575d9..7f2392f 100644 --- a/docs/09-MVP-PLAN.md +++ b/docs/09-MVP-PLAN.md @@ -17,7 +17,7 @@ this repo delivers M0 and the scaffolding for M1–M2. - Document import (native file picker + paste); local extraction (pdf/docx/txt/md). - OpenAI structured parsing → store parsed JSON. - Chunker + embeddings + `vectorStore` persistence (auto re-index on import/notes). -- `rag:search` returns top-k. +- Top-k retrieval (`services/rag/retriever.ts`) — an internal service call, not an IPC channel. - UI: `ProfileEditorPage` (resume/JD upload + paste + parse, notes). ## M2 — Live session core ✅ (implemented) diff --git a/e2e/README.md b/e2e/README.md index df037d4..19bfd07 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -64,7 +64,6 @@ migrations (electron-builder does this when packaging; a bare `out/` run doesn't - Each test runs against an **isolated data dir** (`E2E_USER_DATA`, honored by `src/main/index.ts`) so your real profiles/sessions are never touched. - Data-integrity specs use `window.api` directly rather than clicking through forms — - robust, and they target the exact main/DB paths. (`window.api.rag` is NOT exposed by - the preload — the IPC map lists it but the facade omits it; tests avoid it.) + robust, and they target the exact main/DB paths. - Privacy Mode (content protection) excludes windows from *screen capture*, not from Playwright's CDP connection, so it doesn't interfere here. diff --git a/src/main/services/openai/realtime.ts b/src/main/services/openai/realtime.ts index 77f40c9..8644404 100644 --- a/src/main/services/openai/realtime.ts +++ b/src/main/services/openai/realtime.ts @@ -24,6 +24,7 @@ export class RealtimeTranscriber { private ws: WebSocket | null = null; private ready = false; private closing = false; + private errored = false; // a specific error was already surfaced this connection constructor( private cb: RealtimeCallbacks, @@ -37,6 +38,7 @@ export class RealtimeTranscriber { return; } this.closing = false; + this.errored = false; // GA Realtime API: the beta shape was retired (the `OpenAI-Beta: realtime=v1` // header + `transcription_session.update` event), which is why the server now // rejects beta connections with `beta_api_shape_disabled`. @@ -81,17 +83,22 @@ export class RealtimeTranscriber { this.ws.on('message', (data) => this.handle(data.toString())); this.ws.on('error', (err) => { log.error('realtime: ws error', err.message); - if (!this.closing) this.cb.onError?.(`Realtime transcription error: ${err.message}`); + if (!this.closing) { + this.errored = true; + this.cb.onError?.(`Realtime transcription error: ${err.message}`); + } }); this.ws.on('close', (code, reason) => { this.ready = false; - // An UNEXPECTED close (server dropped us after 401/quota, or a network drop that - // closes without an 'error' event) would otherwise go unreported while the mic - // keeps streaming into a dead socket — the interview silently goes deaf. Surface - // it so the user knows to restart the session. + // An UNEXPECTED close would otherwise go unreported while the mic keeps streaming + // into a dead socket — the interview silently goes deaf. But 'ws' fires 'error' + // THEN 'close' for the same failure, so skip the generic message when a specific + // error was already surfaced (don't clobber "expired key" with "disconnected"). if (!this.closing) { log.warn(`realtime: ws closed (${code}) ${reason.toString()}`); - this.cb.onError?.('Transcription disconnected — stop and resume the interview to reconnect.'); + if (!this.errored) { + this.cb.onError?.('Transcription disconnected — stop and resume the interview to reconnect.'); + } } }); } diff --git a/src/renderer/components/ui.tsx b/src/renderer/components/ui.tsx index 3c0bab4..208752c 100644 --- a/src/renderer/components/ui.tsx +++ b/src/renderer/components/ui.tsx @@ -17,19 +17,25 @@ export function Modal({ children: React.ReactNode; }) { const dialogRef = useRef(null); + // Read onClose through a ref so the focus effect depends ONLY on `open`. Call sites + // pass a new inline onClose each render; if it were a dep, any parent re-render with + // the modal open (e.g. the Cue Card's per-frame audio-meter updates) would re-run + // this effect and yank focus out of the dialog's controls. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onCloseRef.current(); window.addEventListener('keydown', onKey); - // Move focus into the dialog (so keyboard/AT users land inside it) and restore - // it to whatever was focused before, on close. + // Move focus into the dialog (so keyboard/AT users land inside it) and restore it + // to whatever was focused before, on close. `prev` is captured once, when open→true. const prev = document.activeElement as HTMLElement | null; dialogRef.current?.focus(); return () => { window.removeEventListener('keydown', onKey); prev?.focus?.(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; return ( diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index d49d0f6..e460fd2 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -379,7 +379,7 @@ export default function Overlay() { const sendAsk = () => { const t = askText.trim(); if (!t) return; - void api.session.askActive(t); + void api.session.askActive(t).catch(() => {}); // errors surface via sessionError setAskText(''); }; @@ -922,7 +922,6 @@ export default function Overlay() { )} - {meta?.riskWarning && (

⚠ {meta.riskWarning} diff --git a/src/renderer/store/useLiveSession.ts b/src/renderer/store/useLiveSession.ts index 593a60a..70cc65d 100644 --- a/src/renderer/store/useLiveSession.ts +++ b/src/renderer/store/useLiveSession.ts @@ -230,10 +230,16 @@ export const useLiveSession = create((set, get) => { ask: async (question) => { const s = get().session; if (!s || !question) return; - await api.session.ask(s.id, question); + // Show the asked question immediately + keep it even if the answer fails (the + // failure surfaces via sessionError). Swallow the rejection so a failed ask + // doesn't become an unhandled promise rejection. set((st) => ({ - transcript: [...st.transcript, { id: lineId++, speaker: 'you (manual)', text: question }], + transcript: [ + ...st.transcript, + { id: lineId++, speaker: 'you (manual)', text: question }, + ].slice(-MAX_TRANSCRIPT), })); + await api.session.ask(s.id, question).catch(() => {}); }, }; });