/** * Phase 1 — context-window alignment. * * The budgeter must clamp to the model's ACTUALLY-loaded window when it's * smaller than the user's `contextLength` setting, so a model loaded with a * smaller window than the setting never silently overflows the server. */ import { computeBudgetedRequest } from '../src/agent/handlePrompt/computeBudgetedRequest'; import type { ChatMessage } from '../src/agent'; const baseConfig = { contextLength: 32768, maxOutputTokens: 4096, contextSafetyMargin: 512, smallModelContextCap: 0, // disabled autoCompactHistory: false, }; function run(overrides: { actualContextLength?: number; config?: Partial } = {}) { const reqMessages: ChatMessage[] = [{ role: 'user', content: 'hello' }]; return computeBudgetedRequest({ fullSystemPrompt: 'You are a helpful assistant.', reqMessages, actualModel: 'some-13b-model', config: { ...baseConfig, ...overrides.config }, imageCount: 0, actualContextLength: overrides.actualContextLength, }); } describe('computeBudgetedRequest — real-window alignment', () => { test('clamps to the actual loaded window when it is smaller than the setting', () => { const r = run({ actualContextLength: 8192 }); expect(r.windowMismatch).toBe(true); expect(r.effectiveContextLength).toBe(8192); expect(r.ctxLimits.contextLength).toBe(8192); }); test('keeps the configured window when the actual window is unknown', () => { const r = run({ actualContextLength: undefined }); expect(r.windowMismatch).toBe(false); expect(r.effectiveContextLength).toBe(32768); expect(r.ctxLimits.contextLength).toBe(32768); }); test('does not raise the window when the actual window is larger than the setting', () => { const r = run({ actualContextLength: 131072 }); expect(r.windowMismatch).toBe(false); expect(r.effectiveContextLength).toBe(32768); // setting is the lower bound here }); test('ignores a non-positive / non-finite actual window (falls back to setting)', () => { expect(run({ actualContextLength: 0 }).effectiveContextLength).toBe(32768); expect(run({ actualContextLength: -5 }).effectiveContextLength).toBe(32768); expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768); }); }); // ──────────────────────────────────────────────────────────────────────────── // [v2.2.311] KV 캐시 분리 — dynamicContextTail 삽입 계약. // message[0] 은 불변 정적 프롬프트로 남고, 동적 컨텍스트는 마지막 user 메시지 // 직전의 internal system 메시지로 들어가야 프롬프트 프리픽스 캐시가 산다. // ──────────────────────────────────────────────────────────────────────────── describe('computeBudgetedRequest — dynamicContextTail (KV cache split)', () => { const HEAD = 'STATIC HEAD PROMPT'; const TAIL = '[CURRENT DATE/TIME]\nToday: 2026-07-20\n\n[CONTEXT]\nRAG chunk A\n[/CONTEXT]'; // 주의: default parameter 는 명시적 undefined 에도 적용되므로 쓰지 않는다 — // "tail 없음" 케이스를 정확히 표현하기 위해 항상 명시적으로 넘긴다. function runSplit(reqMessages: ChatMessage[], tail: string | undefined) { return computeBudgetedRequest({ fullSystemPrompt: HEAD, dynamicContextTail: tail, reqMessages, actualModel: 'some-13b-model', config: baseConfig, imageCount: 0, }); } test('tail 은 마지막 user 메시지 직전에 internal system 으로 삽입된다', () => { const msgs: ChatMessage[] = [ { role: 'user', content: '첫 질문' }, { role: 'assistant', content: '첫 답' }, { role: 'user', content: '두번째 질문' }, ]; const r = runSplit(msgs, TAIL); expect(r.messagesForRequest[0]).toMatchObject({ role: 'system', content: HEAD }); const idx = r.messagesForRequest.findIndex((m) => m.content === TAIL); expect(idx).toBeGreaterThan(0); expect(r.messagesForRequest[idx].role).toBe('system'); expect(r.messagesForRequest[idx + 1]).toMatchObject({ role: 'user', content: '두번째 질문' }); }); test('continuation(마지막 user 뒤 액션 결과 system)이 있어도 tail 은 user 앞', () => { const msgs: ChatMessage[] = [ { role: 'user', content: '조사해줘' }, { role: 'system', content: '[Result of read_file] ...', internal: true }, ]; const r = runSplit(msgs, TAIL); const tailIdx = r.messagesForRequest.findIndex((m) => m.content === TAIL); const userIdx = r.messagesForRequest.findIndex((m) => m.role === 'user'); const actionIdx = r.messagesForRequest.findIndex((m) => String(m.content).startsWith('[Result of')); expect(tailIdx).toBeLessThan(userIdx); expect(actionIdx).toBeGreaterThan(userIdx); }); test('user 메시지가 없으면 tail 을 히스토리 끝에 붙인다', () => { const msgs: ChatMessage[] = [{ role: 'system', content: 'internal only', internal: true }]; const r = runSplit(msgs, TAIL); const last = r.messagesForRequest[r.messagesForRequest.length - 1]; expect(last.content).toBe(TAIL); }); test('tail 미지정이면 종전 단일-시스템 동작 그대로', () => { const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; const r = runSplit(msgs, undefined); expect(r.messagesForRequest).toHaveLength(2); expect(r.messagesForRequest[0].content).toBe(HEAD); }); test('예산 초과 시 [CONTEXT] truncation 은 tail 에 적용되고 head 는 보존', () => { const hugeTail = `[CONTEXT]\n${'긴 배경 지식 청크. '.repeat(20000)}\n[/CONTEXT]`; const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; const r = computeBudgetedRequest({ fullSystemPrompt: HEAD, dynamicContextTail: hugeTail, reqMessages: msgs, actualModel: 'some-13b-model', config: { ...baseConfig, contextLength: 8192 }, imageCount: 0, }); expect(r.systemTruncated).toBe(true); expect(r.messagesForRequest[0].content).toBe(HEAD); const tailMsg = r.messagesForRequest.find((m) => String(m.content).includes('[CONTEXT]')); expect(tailMsg).toBeDefined(); expect(String(tailMsg!.content).length).toBeLessThan(hugeTail.length); }); test('systemTokens 는 head+tail 합산으로 보고된다', () => { const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; const withTail = runSplit(msgs, TAIL); const withoutTail = runSplit(msgs, undefined); expect(withTail.systemTokens).toBeGreaterThan(withoutTail.systemTokens); }); });