release: ship v1.5.0 with on-device writing suggestions
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.
This commit is contained in:
parent
99f06c253c
commit
5c11ee2fde
104 changed files with 14410 additions and 174 deletions
581
apps/desktop/tests/main/services/input-flow-services.test.ts
Normal file
581
apps/desktop/tests/main/services/input-flow-services.test.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { inputActivity, personalPhrases, suggestions, typingSamples } from '../../../src/main/db/schema'
|
||||
|
||||
type ActivityRow = {
|
||||
id: string
|
||||
date: string
|
||||
hour: number
|
||||
appName: string
|
||||
keystrokes: number
|
||||
shortcuts: number
|
||||
backspaces: number
|
||||
clicks: number
|
||||
doubleClicks: number
|
||||
scrollTicks: number
|
||||
mouseDistancePx: number
|
||||
chars: number
|
||||
words: number
|
||||
sentences: number
|
||||
activeMs: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type PhraseRow = {
|
||||
id: string
|
||||
phrase: string
|
||||
count: number
|
||||
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
|
||||
appName: string | null
|
||||
lastUsedAt: number | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type SuggestionRow = {
|
||||
id: string
|
||||
appName: string | null
|
||||
prefixText: string
|
||||
suggestionText: string
|
||||
candidateCount: number
|
||||
model: string | null
|
||||
latencyMs: number | null
|
||||
accepted: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type SampleRow = {
|
||||
id: string
|
||||
text: string
|
||||
wordCount: number
|
||||
charCount: number
|
||||
appName: string | null
|
||||
windowTitle: string | null
|
||||
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type StreamOptions = { signal?: AbortSignal }
|
||||
type StreamFactory = (prompt: string, options: StreamOptions) => AsyncIterable<string>
|
||||
|
||||
const harness = vi.hoisted(() => {
|
||||
const config = new Map<string, unknown>()
|
||||
const failure = { read: false, delete: false }
|
||||
const model: { available: boolean; streamGenerate: StreamFactory } = {
|
||||
available: false,
|
||||
streamGenerate: async function* () {
|
||||
return
|
||||
}
|
||||
}
|
||||
const rows: {
|
||||
activity: ActivityRow[]
|
||||
phrases: PhraseRow[]
|
||||
samples: SampleRow[]
|
||||
suggestions: SuggestionRow[]
|
||||
} = { activity: [], phrases: [], samples: [], suggestions: [] }
|
||||
|
||||
const rowsFor = (table: unknown): ActivityRow[] | PhraseRow[] | SampleRow[] | SuggestionRow[] => {
|
||||
if (table === inputActivity) return rows.activity
|
||||
if (table === personalPhrases) return rows.phrases
|
||||
if (table === typingSamples) return rows.samples
|
||||
return rows.suggestions
|
||||
}
|
||||
|
||||
const database = {
|
||||
select(selection?: Record<string, unknown>) {
|
||||
if (failure.read) throw new Error('receipt read failed')
|
||||
return {
|
||||
from(table: unknown) {
|
||||
const selectedRows = rowsFor(table)
|
||||
const chain = {
|
||||
where: () => chain,
|
||||
orderBy: () => chain,
|
||||
limit: () => chain,
|
||||
all: () => {
|
||||
const keys = Object.keys(selection ?? {})
|
||||
if (table === suggestions && keys.includes('appName')) {
|
||||
return rows.suggestions.map(({ appName, accepted, latencyMs }) => ({ appName, accepted, latencyMs }))
|
||||
}
|
||||
return [...selectedRows]
|
||||
},
|
||||
get: () => {
|
||||
const keys = Object.keys(selection ?? {})
|
||||
if (keys.includes('total')) {
|
||||
const suggestionRows = rows.suggestions
|
||||
const latencyRows = suggestionRows.filter((row) => row.latencyMs !== null)
|
||||
return {
|
||||
total: suggestionRows.length,
|
||||
accepted: suggestionRows.filter((row) => row.accepted).length,
|
||||
avgLatency:
|
||||
latencyRows.length === 0
|
||||
? null
|
||||
: latencyRows.reduce((total, row) => total + (row.latencyMs ?? 0), 0) /
|
||||
latencyRows.length
|
||||
}
|
||||
}
|
||||
if (keys.includes('value')) return { value: selectedRows.length }
|
||||
return selectedRows[0]
|
||||
}
|
||||
}
|
||||
return chain
|
||||
}
|
||||
}
|
||||
},
|
||||
insert(table: unknown) {
|
||||
return {
|
||||
values(value: Record<string, unknown>) {
|
||||
const selectedRows = rowsFor(table)
|
||||
const apply = () => {
|
||||
if (table === personalPhrases) {
|
||||
const phrase = value.phrase as string
|
||||
const existing = rows.phrases.find((row) => row.phrase === phrase)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
existing.lastUsedAt = value.lastUsedAt as number
|
||||
existing.appName = value.appName as string | null
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedRows.push(value as never)
|
||||
}
|
||||
return {
|
||||
run: () => apply(),
|
||||
onConflictDoUpdate: () => ({ run: () => apply() })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
delete(table: unknown) {
|
||||
const selectedRows = rowsFor(table)
|
||||
const chain = {
|
||||
where: () => chain,
|
||||
run: () => {
|
||||
if (failure.delete) throw new Error('delete failed')
|
||||
selectedRows.splice(0, selectedRows.length)
|
||||
return { changes: 1 }
|
||||
}
|
||||
}
|
||||
return chain
|
||||
},
|
||||
update: () => ({ set: () => ({ where: () => ({ run: () => ({ changes: 1 }) }) }) })
|
||||
}
|
||||
|
||||
const uia = {
|
||||
isAvailable: () => false,
|
||||
lastReason: 'test',
|
||||
lastSuccessAt: 0,
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
available: false,
|
||||
reason: 'test',
|
||||
isPassword: false,
|
||||
isEditable: false,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
textSource: 'none' as const,
|
||||
text: '',
|
||||
caretOffset: null,
|
||||
caretRect: null,
|
||||
elementRect: null,
|
||||
windowTitle: null,
|
||||
appName: null,
|
||||
processId: null,
|
||||
capturedAt: Date.now()
|
||||
}))
|
||||
}
|
||||
return {
|
||||
config,
|
||||
failure,
|
||||
model,
|
||||
rows,
|
||||
database,
|
||||
uia,
|
||||
graph: { continuations: [] as string[], related: [] as string[] }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } }))
|
||||
vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } }))
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: () => harness.database }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => harness.config.get(key),
|
||||
configSet: (key: string, value: unknown) => harness.config.set(key, value)
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined }))
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null }))
|
||||
vi.mock('../../../src/main/services/UiaContextService', () => ({
|
||||
getUiaContextService: () => harness.uia
|
||||
}))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
||||
getPersonalGraphService: () => ({
|
||||
clearAll: vi.fn(),
|
||||
runMaintenance: vi.fn(),
|
||||
indexText: vi.fn(),
|
||||
retrieveContext: () => harness.graph
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({
|
||||
isAvailable: () => harness.model.available,
|
||||
streamGenerate: (prompt: string, options: StreamOptions) => harness.model.streamGenerate(prompt, options)
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: vi.fn() }) }))
|
||||
|
||||
import {
|
||||
getInputTelemetryService,
|
||||
resetInputTelemetryServiceForTests
|
||||
} from '../../../src/main/services/InputTelemetryService'
|
||||
import {
|
||||
getSuggestionService,
|
||||
resetSuggestionServiceForTests
|
||||
} from '../../../src/main/services/SuggestionService'
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
function activity(overrides: Partial<ActivityRow> = {}): ActivityRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(now).toISOString().slice(0, 10),
|
||||
hour: 9,
|
||||
appName: 'Notion.exe',
|
||||
keystrokes: 0,
|
||||
shortcuts: 0,
|
||||
backspaces: 0,
|
||||
clicks: 0,
|
||||
doubleClicks: 0,
|
||||
scrollTicks: 0,
|
||||
mouseDistancePx: 0,
|
||||
chars: 0,
|
||||
words: 0,
|
||||
sentences: 0,
|
||||
activeMs: 0,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0]> = {}) {
|
||||
return {
|
||||
prefix: '오늘 회의 결과를',
|
||||
fullText: '오늘 회의 결과를',
|
||||
caretOffset: 9,
|
||||
anchor: { x: 1, y: 1, width: 1, height: 1 },
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'Notion.exe',
|
||||
windowTitle: '회의록',
|
||||
idleMs: 300,
|
||||
capturedAt: now,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.config.clear()
|
||||
harness.config.set('inputExcludedApps', [])
|
||||
harness.config.set('inputLearnTypedText', true)
|
||||
harness.config.set('suggestionEnabled', true)
|
||||
harness.config.set('suggestionMinPrefixChars', 4)
|
||||
harness.config.set('suggestionTriggerDelayMs', 100)
|
||||
harness.config.set('suggestionMaxRequestsPerMinute', 20)
|
||||
harness.config.set('suggestionDailyBudget', 100)
|
||||
harness.failure.read = false
|
||||
harness.failure.delete = false
|
||||
harness.model.available = false
|
||||
harness.model.streamGenerate = async function* () {
|
||||
return
|
||||
}
|
||||
harness.rows.activity.splice(0)
|
||||
harness.rows.phrases.splice(0)
|
||||
harness.rows.samples.splice(0)
|
||||
harness.rows.suggestions.splice(0)
|
||||
harness.graph.continuations = []
|
||||
harness.graph.related = []
|
||||
resetInputTelemetryServiceForTests()
|
||||
resetSuggestionServiceForTests()
|
||||
harness.uia.getSnapshot.mockClear()
|
||||
})
|
||||
|
||||
describe('입력 플로우 서비스', () => {
|
||||
it('제안 표시 중에만 오래된 키 입력과 mouse-up 후 UIA 포커스를 다시 확인한다', async () => {
|
||||
const telemetry = getInputTelemetryService() as unknown as {
|
||||
_running: boolean
|
||||
_lastKeyAt: number
|
||||
_sampleWhileTyping: () => Promise<void>
|
||||
_handleMouseUp: (event: { x: number; y: number }) => void
|
||||
setSuggestionPresentationActive: (active: boolean) => void
|
||||
}
|
||||
telemetry._running = true
|
||||
telemetry._lastKeyAt = Date.now() - 6000
|
||||
|
||||
await telemetry._sampleWhileTyping()
|
||||
expect(harness.uia.getSnapshot).not.toHaveBeenCalled()
|
||||
|
||||
telemetry.setSuggestionPresentationActive(true)
|
||||
await telemetry._sampleWhileTyping()
|
||||
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useFakeTimers()
|
||||
telemetry._handleMouseUp({ x: 1, y: 1 })
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('시간대 집계에 플로우·마찰과 앱별 제안 품질을 함께 반환한다', () => {
|
||||
harness.rows.activity.push(
|
||||
activity({ hour: 9, chars: 1200, backspaces: 30, activeMs: 60 * 60 * 1000, keystrokes: 1300 }),
|
||||
activity({ id: crypto.randomUUID(), hour: 14, chars: 120, backspaces: 60, activeMs: 20 * 60 * 1000 })
|
||||
)
|
||||
harness.rows.suggestions.push(
|
||||
{ id: 's1', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'b', candidateCount: 1, model: 'm', latencyMs: 100, accepted: true, createdAt: now },
|
||||
{ id: 's2', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'c', candidateCount: 1, model: 'm', latencyMs: 300, accepted: false, createdAt: now },
|
||||
{ id: 's3', appName: 'Slack.exe', prefixText: 'a', suggestionText: 'd', candidateCount: 1, model: 'm', latencyMs: null, accepted: true, createdAt: now }
|
||||
)
|
||||
|
||||
const summary = getInputTelemetryService().getSummary(1)
|
||||
|
||||
expect(summary.hourly[9]).toMatchObject({ chars: 1200, backspaces: 30, activeMs: 3600000 })
|
||||
expect(summary.friction).toMatchObject({ band: 'steady', editsPer100Chars: 6.8 })
|
||||
expect(summary.flowWindows[0]).toMatchObject({ hour: 9 })
|
||||
expect(summary.suggestionApps).toEqual([
|
||||
{ appName: 'Notion.exe', total: 2, accepted: 1, acceptRate: 0.5, avgLatencyMs: 200 },
|
||||
{ appName: 'Slack.exe', total: 1, accepted: 1, acceptRate: 1, avgLatencyMs: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('개인정보 영수증은 로컬 보존 경계와 실제 행 수를 반환한다', () => {
|
||||
harness.rows.activity.push(activity())
|
||||
harness.rows.samples.push({ id: 'sample', text: '학습 문장입니다', wordCount: 2, charCount: 7, appName: null, windowTitle: null, source: 'typed', createdAt: now })
|
||||
harness.rows.phrases.push({ id: 'phrase', phrase: '학습 문장입니다', count: 1, source: 'typed', appName: null, lastUsedAt: now, createdAt: now })
|
||||
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
|
||||
|
||||
expect(getInputTelemetryService().getPrivacyReceipt()).toMatchObject({
|
||||
localOnly: true,
|
||||
rawKeyContentStored: false,
|
||||
retention: {
|
||||
activityDays: 30,
|
||||
typingSamplesDays: 30,
|
||||
suggestionDays: 30,
|
||||
personalPhrases: 'until-deleted'
|
||||
},
|
||||
counts: { activityBuckets: 1, typingSamples: 1, personalPhrases: 1, suggestions: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('개인정보 영수증 조회 실패를 빈 영수증으로 위장하지 않는다', () => {
|
||||
harness.failure.read = true
|
||||
|
||||
expect(() => getInputTelemetryService().getPrivacyReceipt()).toThrow('receipt read failed')
|
||||
})
|
||||
|
||||
it('전체 삭제는 제안 이력까지 제거한다', () => {
|
||||
harness.rows.activity.push(activity())
|
||||
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
|
||||
|
||||
getInputTelemetryService().clearAll()
|
||||
|
||||
expect(getInputTelemetryService().getPrivacyReceipt().counts).toEqual({
|
||||
activityBuckets: 0,
|
||||
typingSamples: 0,
|
||||
personalPhrases: 0,
|
||||
suggestions: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 삭제 실패를 성공처럼 반환하지 않고 메모리 초기화도 건너뛴다', () => {
|
||||
harness.rows.activity.push(activity())
|
||||
const service = getInputTelemetryService() as unknown as {
|
||||
_pending: Map<string, unknown>
|
||||
clearAll: () => void
|
||||
}
|
||||
service._pending.set('pending', {})
|
||||
harness.failure.delete = true
|
||||
|
||||
expect(() => service.clearAll()).toThrow('delete failed')
|
||||
expect(harness.rows.activity).toHaveLength(1)
|
||||
expect(service._pending.size).toBe(1)
|
||||
})
|
||||
|
||||
it('보존 정리는 만료 제안 이력도 같은 경계로 정리한다', () => {
|
||||
harness.rows.suggestions.push({
|
||||
id: 'expired',
|
||||
appName: null,
|
||||
prefixText: '',
|
||||
suggestionText: '만료 후보',
|
||||
candidateCount: 1,
|
||||
model: 'm',
|
||||
latencyMs: 10,
|
||||
accepted: false,
|
||||
createdAt: now - 31 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
const service = getInputTelemetryService() as unknown as {
|
||||
_pruneOldData: (at: number) => void
|
||||
}
|
||||
|
||||
service._pruneOldData(now)
|
||||
|
||||
expect(getInputTelemetryService().getPrivacyReceipt().counts.suggestions).toBe(0)
|
||||
})
|
||||
|
||||
it('학습 문구에 마지막 앱을 저장하고 목록 계약에 매핑한다', () => {
|
||||
const service = getInputTelemetryService() as unknown as {
|
||||
_learnText: (text: string, meta: { appName: string | null; windowTitle: string | null; source: 'typed' }) => void
|
||||
}
|
||||
|
||||
service._learnText('배포 일정을 공유합니다. 다음 조치를 확인합니다.', {
|
||||
appName: 'Slack.exe',
|
||||
windowTitle: '채널',
|
||||
source: 'typed'
|
||||
})
|
||||
|
||||
expect(getInputTelemetryService().listPhrases()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ appName: 'Slack.exe' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('비밀번호 스냅샷은 제외 추천 증거에 넣지 않고 반복 비가독 앱만 추천한다', () => {
|
||||
const service = getInputTelemetryService() as unknown as {
|
||||
_foreground: { appName: string | null }
|
||||
_recordReadabilityEvidence: (snapshot: { isPassword: boolean; isEditable: boolean; textSource: 'none' | 'value'; text: string }) => void
|
||||
}
|
||||
service._foreground.appName = 'Legacy.exe'
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
service._recordReadabilityEvidence({ isPassword: false, isEditable: false, textSource: 'none', text: '' })
|
||||
}
|
||||
service._recordReadabilityEvidence({ isPassword: true, isEditable: false, textSource: 'none', text: '' })
|
||||
|
||||
expect(getInputTelemetryService().getState().exclusionRecommendation).toEqual({
|
||||
appName: 'Legacy.exe',
|
||||
reason: 'repeated-unreadable',
|
||||
samples: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('모델이 없을 때 정책을 지키며 로컬 기억 후보와 출처를 게시한다', () => {
|
||||
harness.rows.phrases.push({ id: 'phrase', phrase: '검토 결과를 공유합니다', count: 4, source: 'typed', appName: 'Notion.exe', lastUsedAt: now, createdAt: now })
|
||||
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
|
||||
|
||||
getSuggestionService().handleTypingContext({
|
||||
prefix: '오늘 회의 결과를',
|
||||
fullText: '오늘 회의 결과를',
|
||||
caretOffset: 9,
|
||||
anchor: { x: 1, y: 1, width: 1, height: 1 },
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
available: true,
|
||||
appName: 'Notion.exe',
|
||||
windowTitle: '회의록',
|
||||
idleMs: 300,
|
||||
capturedAt: now
|
||||
})
|
||||
|
||||
expect(getSuggestionService().getState()).toMatchObject({
|
||||
visible: true,
|
||||
provenance: { mode: 'local-memory', continuationCount: 1, appPhraseCount: 1 }
|
||||
})
|
||||
expect(harness.rows.suggestions[0]).toMatchObject({ model: 'local-memory' })
|
||||
})
|
||||
|
||||
it('timeout abort가 스트림에서 throw되어도 로컬 기억 fallback을 한 번 게시한다', async () => {
|
||||
const context = typingContext()
|
||||
const service = getSuggestionService() as unknown as {
|
||||
_lastContext: typeof context | null
|
||||
_lastContextAt: number
|
||||
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
|
||||
}
|
||||
harness.config.set('llmModelId', 'local-model')
|
||||
harness.config.set('suggestionRequestTimeoutMs', 10)
|
||||
harness.model.available = true
|
||||
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
|
||||
harness.model.streamGenerate = (_prompt, options) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
service._lastContext = context
|
||||
service._lastContextAt = Date.now()
|
||||
|
||||
await service._generate(context.prefix, context, 3, 160)
|
||||
|
||||
expect(getSuggestionService().getState()).toMatchObject({
|
||||
visible: true,
|
||||
provenance: { mode: 'local-memory', continuationCount: 1 }
|
||||
})
|
||||
expect(harness.rows.suggestions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('timeout 중 현재 문맥과 세대가 바뀌면 로컬 기억 fallback을 게시하지 않는다', async () => {
|
||||
const context = typingContext()
|
||||
const service = getSuggestionService() as unknown as {
|
||||
_generationToken: number
|
||||
_lastContext: typeof context | null
|
||||
_lastContextAt: number
|
||||
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
|
||||
}
|
||||
harness.config.set('llmModelId', 'local-model')
|
||||
harness.config.set('suggestionRequestTimeoutMs', 20)
|
||||
harness.model.available = true
|
||||
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
|
||||
harness.model.streamGenerate = (_prompt, options) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
service._lastContext = context
|
||||
service._lastContextAt = Date.now()
|
||||
const changed = typingContext({ prefix: '다른 문맥입니다', appName: 'Slack.exe', windowTitle: '채널' })
|
||||
const invalidate = setTimeout(() => {
|
||||
service._generationToken += 1
|
||||
service._lastContext = changed
|
||||
service._lastContextAt = Date.now()
|
||||
}, 1)
|
||||
|
||||
await service._generate(context.prefix, context, 3, 160)
|
||||
clearTimeout(invalidate)
|
||||
|
||||
expect(getSuggestionService().getState().visible).toBe(false)
|
||||
expect(harness.rows.suggestions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('세대가 달라진 로컬 후보는 게시하지 않아 명시 취소 뒤 되살아나지 않는다', () => {
|
||||
const service = getSuggestionService() as unknown as {
|
||||
_generationToken: number
|
||||
_publishLocalMemory: (
|
||||
prefix: string,
|
||||
context: Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0],
|
||||
maxCandidates: number,
|
||||
maxChars: number,
|
||||
latencyMs: number,
|
||||
token: number
|
||||
) => boolean
|
||||
}
|
||||
service._generationToken = 2
|
||||
|
||||
expect(
|
||||
service._publishLocalMemory(
|
||||
'오늘 회의 결과를',
|
||||
{
|
||||
prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, isPassword: false,
|
||||
isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: null,
|
||||
idleMs: 300, capturedAt: now
|
||||
},
|
||||
3,
|
||||
160,
|
||||
0,
|
||||
1
|
||||
)
|
||||
).toBe(false)
|
||||
expect(getSuggestionService().getState().visible).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue