refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 9aa7302944
commit 190b6db284
13 changed files with 1994 additions and 496 deletions

View file

@ -0,0 +1,219 @@
import { describe, expect, it } from 'vitest'
import {
SUGGESTION_CONTEXT_MAX_CHARS,
buildLocalSuggestionCandidates,
decideSuggestionStep,
matchesSessionPrefix,
normalizeSessionPrefix,
normalizeSuggestionText,
parseSuggestionCandidates,
sanitizeSuggestionLine,
stripModelArtifacts,
type SuggestionPolicyInput,
type SuggestionStepInput
} from '../src/input-intelligence'
import * as suggestionText from '../src/suggestion-text'
import * as terminalPrompt from '../src/terminal-prompt'
import * as inputIntelligence from '../src/input-intelligence'
const LONG_PREFIX = `${'가'.repeat(450)} 회의 결과를 정리해서`
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
return {
enabled: true,
modelAvailable: true,
overlayVisible: false,
composing: false,
hasSelection: false,
isPassword: false,
isEditable: true,
appName: 'Slack.exe',
excludedApps: [],
editedSinceFocus: true,
typedRecently: true,
prefix: '오늘 회의 결과를 정리해서',
idleMs: 2000,
triggerDelayMs: 600,
minPrefixChars: 8,
sinceLastRequestMs: 60_000,
minIntervalMs: 5000,
requestsThisMinute: 0,
maxRequestsPerMinute: 6,
requestsToday: 0,
dailyBudget: 500,
...overrides
}
}
function step(overrides: Partial<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
return decideSuggestionStep({
policy: policy(policyOverrides),
sessionVisible: false,
sessionPrefix: '',
coolingDown: false,
userDismissedQuiet: false,
lastRequestedPrefix: '',
modelWarm: true,
warmUpBackoff: false,
...overrides
})
}
describe('세션 접두 정규화 (400자 초과 입력창)', () => {
it('세션이 저장한 400자 창과 잘리지 않은 현재 접두를 같은 세션으로 본다', () => {
const stored = normalizeSessionPrefix(LONG_PREFIX)
expect(stored).toHaveLength(SUGGESTION_CONTEXT_MAX_CHARS)
expect(matchesSessionPrefix(stored, LONG_PREFIX)).toBe(true)
expect(matchesSessionPrefix(stored, `${LONG_PREFIX} `)).toBe(true)
})
it('긴 입력에서도 이어 치기는 세션 종료, 마지막 글자 IME 조합은 유지', () => {
const stored = normalizeSessionPrefix(LONG_PREFIX)
expect(matchesSessionPrefix(stored, `${LONG_PREFIX} 추가`)).toBe(false)
expect(matchesSessionPrefix(stored, `${LONG_PREFIX.slice(0, -1)}사`)).toBe(true)
})
it('decideSuggestionStep 은 400자 넘는 같은 접두로 표시 중 세션을 끝내지 않는다', () => {
const result = step(
{ sessionVisible: true, sessionPrefix: normalizeSessionPrefix(LONG_PREFIX), lastRequestedPrefix: LONG_PREFIX },
{ prefix: LONG_PREFIX, overlayVisible: true }
)
expect(result).not.toMatchObject({ action: 'end-session' })
expect(result).toMatchObject({ action: 'settle', then: 'record' })
})
})
describe('decideSuggestionStep — 쿨다운 중 표시된 세션', () => {
const visible = { sessionVisible: true, sessionPrefix: '오늘 회의 결과를 정리해서', coolingDown: true }
it('다음 후보 단축키(디바운스 구간)에서도 세션을 닫거나 다시 만들지 않는다', () => {
expect(step(visible, { overlayVisible: true, idleMs: 100 })).toEqual({
action: 'settle',
reason: 'cooldown',
tryLocalMemory: false,
then: 'record'
})
})
it('멈춘 상태에서도 로컬 기억 세션을 다시 게시하지 않는다 (선택 초기화 · 이력 중복 방지)', () => {
expect(step(visible, { overlayVisible: true, idleMs: 5000 })).toMatchObject({ tryLocalMemory: false, then: 'record' })
})
it('타이핑이 끊겨도(not-typing) 읽는 중인 세션을 닫지 않는다', () => {
expect(step(visible, { overlayVisible: true, typedRecently: false })).toMatchObject({ then: 'record' })
})
it('빠른 실패 직후(최소 간격 안)에도 방금 게시한 폴백을 닫지 않는다', () => {
expect(step(visible, { overlayVisible: true, sinceLastRequestMs: 100 })).toMatchObject({ then: 'record' })
})
it('비밀번호 칸·선택 같은 하드 차단은 쿨다운 중에도 세션을 닫는다', () => {
expect(step(visible, { overlayVisible: true, isPassword: true })).toMatchObject({
reason: 'cooldown',
tryLocalMemory: false,
then: 'dismiss'
})
})
it('표시 중인 것이 없으면 로컬 기억 게시를 시도한다', () => {
expect(step({ coolingDown: true })).toEqual({
action: 'settle',
reason: 'cooldown',
tryLocalMemory: true,
then: 'dismiss'
})
})
})
describe('decideSuggestionStep — 순서와 모델 온기', () => {
it('정상 경로는 생성한다', () => {
expect(step()).toEqual({ action: 'generate', prefix: '오늘 회의 결과를 정리해서' })
})
it('콜드 모델은 워밍업, 워밍업 실패 직후에는 워밍업을 다시 쏘지 않는다', () => {
expect(step({ modelWarm: false })).toMatchObject({ reason: 'model-unavailable', then: 'warm-up' })
expect(step({ modelWarm: false, warmUpBackoff: true })).toMatchObject({
reason: 'generation-failed',
tryLocalMemory: true,
then: 'record'
})
})
it('사용자 닫기 직후는 조용하다', () => {
expect(step({ userDismissedQuiet: true })).toMatchObject({ reason: 'cooldown', then: 'record' })
})
it('같은 텍스트로 이미 요청했으면 unchanged', () => {
expect(
step({ lastRequestedPrefix: '오늘 회의 결과를 정리해서' }, { overlayVisible: true })
).toMatchObject({ reason: 'unchanged', then: 'record' })
})
it('모델 서버가 없으면 로컬 기억을 먼저 시도하고 준비 중을 보여준다', () => {
expect(step({}, { modelAvailable: false })).toMatchObject({
reason: 'model-unavailable',
tryLocalMemory: true,
then: 'show-warming'
})
})
it('표시 중 세션의 접두가 자라면 세션을 끝낸다', () => {
expect(
step({ sessionVisible: true, sessionPrefix: '오늘 회의 결과를' }, { prefix: '오늘 회의 결과를 정리해서' })
).toEqual({ action: 'end-session', reason: 'stale' })
})
})
describe('후보 정제 — 문장 앞 숫자 보존', () => {
it.each([
['3 pm in the lobby', '3 pm in the lobby'],
['10 minutes later I will call', '10 minutes later I will call'],
['2026 roadmap is ready', '2026 roadmap is ready'],
['3 시에 로비에서 봬요', '3 시에 로비에서 봬요']
])('숫자로 시작하는 문장 %s 을 지우지 않는다', (input, expected) => {
expect(sanitizeSuggestionLine(input)).toBe(expected)
})
it.each([
['1. 첫 항목입니다', '첫 항목입니다'],
['10) 열 번째 항목', '열 번째 항목'],
['2] 괄호 번호', '괄호 번호'],
['- 불릿 항목', '불릿 항목'],
['• 점 불릿', '점 불릿']
])('목록 표지 %s 는 걷어낸다', (input, expected) => {
expect(sanitizeSuggestionLine(input)).toBe(expected)
})
it('모델 출력 파싱도 앞 숫자를 보존한다', () => {
expect(parseSuggestionCandidates('10 minutes later I will call', 'I will be there in')).toEqual([
'10 minutes later I will call'
])
})
it('로컬 기억 힌트(사용자 자신의 글)는 모델용 정제를 거치지 않는다', () => {
expect(
buildLocalSuggestionCandidates('Let us meet at', {
continuationHints: ['3 pm in the lobby', '"quoted" plan: ready'],
relatedHints: [],
phraseHints: []
})
).toEqual(['3 pm in the lobby', '"quoted" plan: ready'])
})
it('stripModelArtifacts 는 지시문 되풀이를 버리고, normalizeSuggestionText 는 내용을 바꾸지 않는다', () => {
expect(stripModelArtifacts('Suggestion: 이어집니다')).toBeNull()
expect(stripModelArtifacts('{{prefix}}')).toBeNull()
expect(normalizeSuggestionText(' 1. 그대로 둔다 ')).toBe('1. 그대로 둔다')
expect(normalizeSuggestionText('a')).toBeNull()
})
})
describe('모듈 분리 — 기존 import 경로 유지', () => {
it('input-intelligence 가 분리된 모듈의 같은 함수를 다시 export 한다', () => {
expect(inputIntelligence.sanitizeSuggestionLine).toBe(suggestionText.sanitizeSuggestionLine)
expect(inputIntelligence.buildLocalSuggestionCandidates).toBe(suggestionText.buildLocalSuggestionCandidates)
expect(inputIntelligence.endsSentence).toBe(suggestionText.endsSentence)
expect(inputIntelligence.extractTerminalPromptPrefix).toBe(terminalPrompt.extractTerminalPromptPrefix)
expect(inputIntelligence.SUGGESTION_MAX_OUTPUT_CHARS).toBe(160)
})
})