Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const config = vi.hoisted(() => ({
|
|
suggestionEnabled: true,
|
|
suggestionModelId: 'gemma4:e4b',
|
|
llmModelId: 'gemma4:e4b',
|
|
inputExcludedApps: [],
|
|
suggestionTriggerDelayMs: 600,
|
|
suggestionMinPrefixChars: 8,
|
|
suggestionMaxRequestsPerMinute: 6,
|
|
suggestionDailyBudget: 500,
|
|
suggestionRequestTimeoutMs: 8000,
|
|
inputLearnTypedText: false,
|
|
inputTelemetryEnabled: false,
|
|
suggestionOverlayInteractive: true
|
|
}))
|
|
const localLlm = vi.hoisted(() => ({
|
|
isAvailable: vi.fn(() => true),
|
|
streamGenerate: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
|
configGet: vi.fn((key: keyof typeof config) => config[key]),
|
|
configSet: vi.fn()
|
|
}))
|
|
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: () => localLlm
|
|
}))
|
|
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
|
|
getInputTelemetryService: () => ({ listPhrases: vi.fn(() => []), recordExternalText: vi.fn() })
|
|
}))
|
|
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
|
getPersonalGraphService: () => ({ retrieveContext: vi.fn(() => ({ continuations: [], related: [] })) })
|
|
}))
|
|
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
|
getTextInsertService: () => ({ insertText: vi.fn() })
|
|
}))
|
|
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
|
|
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
|
|
|
|
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
|
const context = {
|
|
prefix: PREFIX,
|
|
fullText: PREFIX,
|
|
caretOffset: PREFIX.length,
|
|
anchor: null,
|
|
isPassword: false,
|
|
isEditable: true,
|
|
isComposing: false,
|
|
hasSelection: false,
|
|
available: true,
|
|
appName: 'notepad.exe',
|
|
windowTitle: 'notes',
|
|
idleMs: 1000,
|
|
capturedAt: Date.now()
|
|
}
|
|
|
|
interface InternalSuggestionService {
|
|
_abort: AbortController | null
|
|
_consecutiveFailures: number
|
|
_cooldownUntil: number
|
|
_inFlight: boolean
|
|
_lastRequestAt: number
|
|
_generate(prefix: string, currentContext: typeof context, maxCandidates: number, maxChars: number): Promise<void>
|
|
_abortStaleGeneration(currentPrefix: string): void
|
|
_watchdog(): void
|
|
_generationToken: number
|
|
}
|
|
|
|
function waitForAbort(signal: AbortSignal | undefined): AsyncGenerator<string> {
|
|
return (async function* () {
|
|
await new Promise<void>((_resolve, reject) => {
|
|
if (!signal) {
|
|
reject(new Error('missing abort signal'))
|
|
return
|
|
}
|
|
signal.addEventListener('abort', () => reject(new Error('intentional abort')), { once: true })
|
|
})
|
|
yield 'unreachable'
|
|
})()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
Object.assign(config, {
|
|
suggestionEnabled: true,
|
|
suggestionModelId: 'gemma4:e4b',
|
|
llmModelId: 'gemma4:e4b',
|
|
inputExcludedApps: [],
|
|
suggestionTriggerDelayMs: 600,
|
|
suggestionMinPrefixChars: 8,
|
|
suggestionMaxRequestsPerMinute: 6,
|
|
suggestionDailyBudget: 500,
|
|
suggestionRequestTimeoutMs: 8000,
|
|
inputLearnTypedText: false,
|
|
inputTelemetryEnabled: false,
|
|
suggestionOverlayInteractive: true
|
|
})
|
|
localLlm.isAvailable.mockReset()
|
|
localLlm.isAvailable.mockReturnValue(true)
|
|
localLlm.streamGenerate.mockReset()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
const { resetSuggestionServiceForTests } = await import('../../../src/main/services/SuggestionService')
|
|
resetSuggestionServiceForTests()
|
|
config.suggestionEnabled = true
|
|
vi.useRealTimers()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('SuggestionService warm-up', () => {
|
|
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => {
|
|
localLlm.streamGenerate.mockImplementation(async function* () {
|
|
yield 'ok'
|
|
})
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const warmingStates: boolean[] = []
|
|
service.on('state-changed', (state) => warmingStates.push(state.warmingUp))
|
|
|
|
await Promise.all([service.warmUp(), service.warmUp()])
|
|
|
|
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
|
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
|
|
'hi',
|
|
expect.objectContaining({ maxTokens: 1, keepAlive: '2m' })
|
|
)
|
|
expect(warmingStates).toEqual([true, false])
|
|
})
|
|
|
|
it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => {
|
|
vi.useFakeTimers()
|
|
localLlm.isAvailable.mockReturnValue(false)
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const warmUp = service.warmUp()
|
|
|
|
config.suggestionEnabled = false
|
|
service.applyConfig()
|
|
await warmUp
|
|
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
expect(localLlm.streamGenerate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('dismiss 취소는 실패 쿨다운을 올리거나 재귀 생성하지 않는다', async () => {
|
|
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
|
waitForAbort(options.signal)
|
|
)
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const internal = service as unknown as InternalSuggestionService
|
|
const cleared: string[] = []
|
|
service.on('cleared', ({ reason }) => cleared.push(reason))
|
|
const generation = internal._generate(PREFIX, context, 3, 240)
|
|
|
|
await Promise.resolve()
|
|
service.dismiss('dismissed')
|
|
await generation
|
|
|
|
expect(internal._consecutiveFailures).toBe(0)
|
|
expect(internal._cooldownUntil).toBe(0)
|
|
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
|
expect(cleared).toEqual(['dismissed'])
|
|
})
|
|
|
|
it('생성 중 동일 접두의 주기 스냅샷은 요청을 취소하지 않는다', async () => {
|
|
let signal: AbortSignal | undefined
|
|
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
|
signal = options.signal
|
|
return waitForAbort(options.signal)
|
|
})
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const internal = service as unknown as InternalSuggestionService
|
|
const generation = internal._generate(PREFIX, context, 3, 240)
|
|
|
|
await Promise.resolve()
|
|
service.handleTypingContext(context)
|
|
|
|
expect(signal?.aborted).toBe(false)
|
|
expect(internal._inFlight).toBe(true)
|
|
service.dismiss('dismissed')
|
|
await generation
|
|
})
|
|
|
|
it.each([
|
|
['선택', { hasSelection: true }, 'selection-active'],
|
|
['포커스 이탈', { isEditable: false }, 'not-editable']
|
|
])('생성 중 %s은 후보가 없어도 취소하고 정확한 cleared 사유를 낸다', async (_case, update, reason) => {
|
|
let signal: AbortSignal | undefined
|
|
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
|
signal = options.signal
|
|
return waitForAbort(options.signal)
|
|
})
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const cleared: string[] = []
|
|
service.on('cleared', ({ reason: clearedReason }) => cleared.push(clearedReason))
|
|
const generation = (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240)
|
|
|
|
await Promise.resolve()
|
|
expect(service.getState()).toMatchObject({ candidates: [], generating: true })
|
|
service.handleTypingContext({ ...context, ...update })
|
|
await generation
|
|
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(cleared).toEqual([reason])
|
|
expect(service.getState()).toMatchObject({ candidates: [], generating: false, partialText: null })
|
|
})
|
|
|
|
it('후보 게시 updated 상태에는 생성 플래그와 부분 텍스트가 남지 않는다', async () => {
|
|
localLlm.streamGenerate.mockImplementation(async function* () {
|
|
yield '다음 단계도 확인하겠습니다.'
|
|
})
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const states: Array<{ candidates: unknown[]; generating: boolean; partialText: string | null }> = []
|
|
service.on('updated', (state) => states.push(state))
|
|
|
|
await (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240)
|
|
|
|
const published = states.find((state) => state.candidates.length > 0)
|
|
expect(published).toMatchObject({ generating: false, partialText: null })
|
|
})
|
|
|
|
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', async () => {
|
|
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
|
waitForAbort(options.signal)
|
|
)
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const internal = service as unknown as InternalSuggestionService
|
|
const cleared: string[] = []
|
|
service.on('cleared', ({ reason }) => cleared.push(reason))
|
|
const generation = internal._generate(PREFIX, context, 3, 240)
|
|
|
|
await Promise.resolve()
|
|
internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다')
|
|
await generation
|
|
|
|
expect(internal._consecutiveFailures).toBe(0)
|
|
expect(internal._cooldownUntil).toBe(0)
|
|
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
|
expect(cleared).toEqual([])
|
|
})
|
|
|
|
it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => {
|
|
let signal: AbortSignal | undefined
|
|
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
|
signal = options.signal
|
|
return waitForAbort(options.signal)
|
|
})
|
|
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
|
const service = getSuggestionService()
|
|
const internal = service as unknown as InternalSuggestionService
|
|
const cleared: string[] = []
|
|
service.on('cleared', ({ reason }) => cleared.push(reason))
|
|
const generation = internal._generate(PREFIX, context, 3, 240)
|
|
|
|
await Promise.resolve()
|
|
internal._lastRequestAt = Date.now() - 13001
|
|
internal._watchdog()
|
|
await generation
|
|
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(internal._inFlight).toBe(false)
|
|
expect(internal._consecutiveFailures).toBe(0)
|
|
expect(cleared).toEqual([])
|
|
})
|
|
})
|