// tests/main/services/suggestion-redteam-r3-1.test.ts // 제안 서비스 회귀 (r3-1): // - 케어렛을 모르는 문서 중간 편집 뒤에는 수락하지 않는다 // - 다른 기능의 짧은 keep_alive 로 모델이 내려갔으면 warm 이 아니다 / 시간 초과면 콜드로 본다 // - 입력 수집 동의가 없으면 모델을 데우지 않고, 철회하면 세션·보관 문맥을 버린다 // - 한 세션은 한 이력 행이다 (채우기 후보는 행을 늘리지 않고, 수락은 세션 행에 기록) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PersonalPhrase } from '@d3ro/core/input-intelligence' const config = vi.hoisted(() => ({ suggestionEnabled: true as boolean, suggestionModelId: 'model-a' as string | null, llmModelId: 'model-a' as string | null, inputExcludedApps: [] as string[], suggestionTriggerDelayMs: 600, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 12, suggestionDailyBudget: 500, suggestionRequestTimeoutMs: 8000, inputLearnTypedText: false as boolean, inputTelemetryEnabled: false as boolean, inputTelemetryPaused: false as boolean, suggestionOverlayInteractive: true, insertMethod: 'clipboard' })) const harness = vi.hoisted(() => ({ isAvailable: vi.fn(() => true), streamGenerate: vi.fn(), insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })), phrases: [] as PersonalPhrase[] })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn((key: keyof typeof config) => config[key]), configSet: vi.fn((key: keyof typeof config, value: never) => { config[key] = value }) })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) })) vi.mock('../../../src/main/services/LocalLLMService', () => ({ getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate }) })) vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => ({ listPhrases: () => harness.phrases }) })) vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) }) })) vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: harness.insertText }) })) vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true })) vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService' import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget' import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker' import type { TypingContext } from '../../../src/main/services/InputTelemetryService' const PREFIX = '오늘 회의에서 논의한 내용을 정리해서' const SUGGESTION = '다음 단계도 확인하겠습니다.' function typingContext(overrides: Partial = {}): TypingContext { const prefix = overrides.prefix ?? PREFIX return { prefix, fullText: prefix, caretOffset: prefix.length, anchor: { x: 100, y: 100, width: 2, height: 20 }, anchorKind: 'caret', isPassword: false, isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Slack.exe', windowTitle: 'general', windowHandle: 101, idleMs: 2000, capturedAt: Date.now(), editedSinceFocus: true, typedRecently: true, ...overrides } } interface Fakes { deps: SuggestionServiceDeps record: ReturnType markAccepted: ReturnType expectInsert: ReturnType recordAccepted: ReturnType } function fakes(): Fakes { const record = vi.fn() const markAccepted = vi.fn() const expectInsert = vi.fn(() => vi.fn()) const recordAccepted = vi.fn() return { record, markAccepted, expectInsert, recordAccepted, deps: { repository: { record, markAccepted, list: vi.fn(() => []) }, budget: new SuggestionBudget(), warmth: new ModelWarmTracker(), foreground: { currentWindowHandle: () => 101 }, learning: { expectProgrammaticInsert: expectInsert, recordAccepted } } } } function streamOf(text: string): AsyncGenerator { return (async function* () { yield text })() } /** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */ function gatedStream(signal: AbortSignal | undefined, gate: Promise): AsyncGenerator { return (async function* () { const text = await new Promise((resolve, reject) => { signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) void gate.then(resolve) }) yield text })() } interface Internal { _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise _lastContext: TypingContext | null _inFlight: boolean _warmUpPromise: Promise | null } let services: SuggestionService[] = [] function create(deps: SuggestionServiceDeps, warm = true): SuggestionService { if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = new SuggestionService(deps) services.push(service) return service } import { ModelResidencyLedger, parseKeepAliveMs } from '../../../src/main/services/llm/model-residency' beforeEach(() => { config.suggestionEnabled = true config.suggestionModelId = 'model-a' config.llmModelId = 'model-a' config.suggestionRequestTimeoutMs = 8000 config.inputLearnTypedText = false config.inputTelemetryEnabled = false config.inputTelemetryPaused = false harness.isAvailable.mockReset() harness.isAvailable.mockReturnValue(true) harness.streamGenerate.mockReset() harness.insertText.mockClear() harness.phrases = [] }) afterEach(() => { for (const service of services) service.dispose() services = [] }) async function flush(): Promise { for (let i = 0; i < 8; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)) } describe('케어렛을 모르는 필드 (item 3)', () => { it('문서 중간 편집 뒤(caretReliable=false)에는 표시 중인 후보를 수락하지 않는다', async () => { harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const fake = fakes() const service = create(fake.deps) await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) expect(service.isVisible).toBe(true) const internal = service as unknown as Internal internal._lastContext = typingContext({ caretOffset: null, caretReliable: false }) const result = await service.accept() expect(result).toEqual({ ok: false, reason: 'caret-unknown' }) expect(harness.insertText).not.toHaveBeenCalled() }) it('케어렛을 모르는 문맥이 오면 세션을 닫는다', async () => { harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const fake = fakes() const service = create(fake.deps) await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) service.handleTypingContext(typingContext({ caretOffset: null, caretReliable: false })) expect(service.isVisible).toBe(false) expect(service.getState().lastSkipReason).toBe('caret-unknown') }) }) describe('모델 상주 추정 (item 4)', () => { it('keep_alive 문자열을 해석한다', () => { expect(parseKeepAliveMs('10m')).toBe(600_000) expect(parseKeepAliveMs('2m')).toBe(120_000) expect(parseKeepAliveMs(undefined)).toBe(300_000) expect(parseKeepAliveMs('1h30m')).toBe(5_400_000) }) it('같은 모델을 더 짧은 keep_alive 로 쓴 요청이 있으면 그 만료 뒤에는 warm 이 아니다', () => { const ledger = new ModelResidencyLedger() const tracker = new ModelWarmTracker(ledger) const t0 = 1_000_000 ledger.noteRequest('model-a', '10m', t0) tracker.noteWarm('model-a', t0 + 570_000) // 1분 뒤 음성 대화가 같은 모델을 keep_alive 2m 로 사용 ledger.noteRequest('model-a', '2m', t0 + 60_000) expect(tracker.isWarm('model-a', t0 + 120_000)).toBe(true) expect(tracker.isWarm('model-a', t0 + 6 * 60_000)).toBe(false) }) it('warm 이라 믿은 요청이 시간 초과하면 콜드로 본다 (다음 멈춤은 워밍업)', async () => { config.suggestionRequestTimeoutMs = 20 harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => gatedStream(options.signal, new Promise(() => undefined)) ) const fake = fakes() const service = create(fake.deps) expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(true) await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(false) }) }) describe('입력 수집 동의 (item 7)', () => { it('동의 없이 제안을 켜면 모델을 데우지 않는다', async () => { const fake = fakes() const service = create(fake.deps, false) service.setEnabled(true) await flush() expect(harness.streamGenerate).not.toHaveBeenCalled() }) it('동의를 철회하면 떠 있는 세션을 닫고 보관 문맥을 버린다 (수동 요청이 옛 접두로 생성하지 않음)', async () => { config.inputTelemetryEnabled = true harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const fake = fakes() const service = create(fake.deps) service.handleTypingContext(typingContext()) await flush() expect(service.isVisible).toBe(true) config.inputTelemetryEnabled = false harness.streamGenerate.mockClear() service.handleInputConsentChanged() expect(service.isVisible).toBe(false) expect(await service.requestNow()).toEqual({ ok: false, reason: 'not-editable' }) expect(harness.streamGenerate).not.toHaveBeenCalled() }) }) describe('제안 이력은 세션당 한 행 (item 8)', () => { function sessionRepository() { return { record: vi.fn(() => 'row-1'), updateSessionCandidates: vi.fn(), markSessionAccepted: vi.fn(), markAccepted: vi.fn(), list: vi.fn(() => []) } } it('채우기 후보는 새 행(지연 0)을 만들지 않고 세션 행의 후보 수만 갱신한다', async () => { const outputs = ['첫 번째 후보 문장입니다.', '두 번째 후보 문장입니다.', '세 번째 후보 문장입니다.'] let call = 0 harness.streamGenerate.mockImplementation(() => streamOf(outputs[Math.min(call++, outputs.length - 1)])) const fake = fakes() const repository = sessionRepository() fake.deps.repository = repository const service = create(fake.deps) await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) await flush() expect(service.getState().candidates.length).toBeGreaterThanOrEqual(2) expect(repository.record).toHaveBeenCalledTimes(1) expect(repository.updateSessionCandidates).toHaveBeenCalledWith('row-1', expect.any(Number)) }) it('로컬 기억 세션에서 2번째 후보를 수락하면 그 세션 행에 기록한다', async () => { config.suggestionModelId = null config.llmModelId = null harness.phrases = [ { id: 'p1', phrase: '다음 단계도 확인하겠습니다.', count: 3, score: 3, lastUsedAt: Date.now(), appName: null, source: 'typed' }, { id: 'p2', phrase: '회의록은 내일 공유드리겠습니다.', count: 2, score: 2, lastUsedAt: Date.now(), appName: null, source: 'typed' } ] as unknown as typeof harness.phrases const fake = fakes() const repository = sessionRepository() fake.deps.repository = repository const service = create(fake.deps, false) const context = typingContext({ prefix: '오늘 회의 정리했습니다.' }) const internal = service as unknown as Internal internal._lastContext = context await internal._generate(context.prefix, context, 3, 160) const state = service.getState() expect(state.candidates.length).toBeGreaterThanOrEqual(2) expect(repository.record).toHaveBeenCalledTimes(1) const second = state.candidates[1].text expect(await service.accept(1)).toEqual({ ok: true }) expect(repository.markSessionAccepted).toHaveBeenCalledWith('row-1', second) expect(repository.markAccepted).not.toHaveBeenCalled() }) })