Files
connectai/tests/responseRecovery.test.ts
T

152 lines
8.6 KiB
TypeScript

import {
extractVisibleFinal,
shouldFinalOnlyRetry,
shouldAutoContinue,
looksCutOff,
mergeContinuationParts,
buildContinuationUserPrompt,
} from '../src/core/responseRecovery';
describe('responseRecovery.extractVisibleFinal — thought quarantine', () => {
it('leaves a plain answer untouched', () => {
const out = extractVisibleFinal('안녕하세요! 무엇을 도와드릴까요?');
expect(out.visible).toBe('안녕하세요! 무엇을 도와드릴까요?');
expect(out.hadHiddenReasoning).toBe(false);
expect(out.wasThoughtOnly).toBe(false);
});
it('keeps only the Harmony `final` channel and discards analysis', () => {
const raw = '<|channel|>analysis<|message|>Let me think about this carefully...<|end|><|start|>assistant<|channel|>final<|message|>최종 답변입니다.';
const out = extractVisibleFinal(raw);
expect(out.visible).toBe('최종 답변입니다.');
expect(out.hadFinalChannel).toBe(true);
expect(out.hadHiddenReasoning).toBe(true);
expect(out.hiddenReasoning).toContain('think about this');
expect(out.wasThoughtOnly).toBe(false);
});
it('strips an UNCLOSED thought channel (model ran out of tokens mid-thought) → thought-only', () => {
const raw = '<|channel>thought\nThinking Process:\nLet me figure out how to approach this and';
const out = extractVisibleFinal(raw);
expect(out.visible).toBe('');
expect(out.hadHiddenReasoning).toBe(true);
expect(out.wasThoughtOnly).toBe(true);
expect(shouldFinalOnlyRetry(out)).toBe(true);
});
it('strips a closed <think>…</think> block', () => {
const out = extractVisibleFinal('<think>reasoning here, multi\nline</think>\n\n실제 답변입니다.');
expect(out.visible).toBe('실제 답변입니다.');
expect(out.hadHiddenReasoning).toBe(true);
});
it('strips an unclosed <think> running to EOS → thought-only', () => {
const out = extractVisibleFinal("<think>I'm thinking and then I run out of");
expect(out.visible).toBe('');
expect(out.wasThoughtOnly).toBe(true);
});
it('strips a leading "Thinking Process:" block up to the answer boundary', () => {
const out = extractVisibleFinal('Thinking Process:\nStep 1: consider X\nStep 2: consider Y\n## 요약\n실제 답변 본문입니다.');
expect(out.visible).toContain('## 요약');
expect(out.visible).toContain('실제 답변 본문');
expect(out.visible).not.toContain('Step 1');
expect(out.hadHiddenReasoning).toBe(true);
});
it('treats a leading "Thinking Process:" with no answer boundary as thought-only', () => {
const out = extractVisibleFinal('Thinking Process:\nStep 1...\nStep 2... and I ran out of tokens here');
expect(out.visible).toBe('');
expect(out.wasThoughtOnly).toBe(true);
});
it('does NOT strip a legitimate "## Thinking Process" markdown heading (no colon)', () => {
const out = extractVisibleFinal('## Thinking Process\n여기서는 사고 과정 자체를 설명하는 답변입니다.');
expect(out.visible).toContain('## Thinking Process');
expect(out.visible).toContain('사고 과정 자체를 설명');
expect(out.hadHiddenReasoning).toBe(false);
});
it('handles empty / whitespace input', () => {
expect(extractVisibleFinal('').visible).toBe('');
expect(extractVisibleFinal(' \n ').visible).toBe('');
expect(extractVisibleFinal(null as any).visible).toBe('');
expect(extractVisibleFinal('').wasThoughtOnly).toBe(false);
});
});
describe('responseRecovery.looksCutOff', () => {
it('flags answers that plainly end mid-sentence / mid-structure', () => {
expect(looksCutOff('당신은 복잡한 아이디어나 목표를 구체적인 실행 계획과 체계적인 문서화로')).toBe(true); // ends with the particle "로"
expect(looksCutOff('우리는 이 문제를 해결하기 위해 다음과 같은 단계를')).toBe(true); // ends with object marker "를"
expect(looksCutOff('the implementation is not yet complete and we need to')).toBe(true); // mid-English
expect(looksCutOff('the items are: foo, bar,')).toBe(true); // trailing comma
expect(looksCutOff('here is the code:\n```python\nprint("hi")')).toBe(true); // unclosed fence
expect(looksCutOff('정리하면 다음 항목들이 중요합니다:\n- 첫 번째 항목\n- 두 번째 항목\n- ')).toBe(true); // dangling bullet
});
it('does NOT flag complete-looking answers', () => {
expect(looksCutOff('이것은 완전히 끝난 답변이고 마침표도 붙어 있습니다.')).toBe(false);
expect(looksCutOff('이것은 마침표 없이 끝나는 한국어 문장입니다')).toBe(false); // ends with "다" — valid
expect(looksCutOff('네, 그렇게 하면 됩니다')).toBe(false);
expect(looksCutOff('done.')).toBe(false);
expect(looksCutOff('짧음')).toBe(false); // too short to judge
});
});
describe('responseRecovery.shouldAutoContinue', () => {
it('continues when the engine reports the output cap was hit', () => {
expect(shouldAutoContinue('output-limit', 'x'.repeat(200), 3500, 4096)).toBe(true);
expect(shouldAutoContinue('output-limit', 'x'.repeat(200), 50, 4096)).toBe(true); // engine said so → trust it
});
it('continues when generation reached ~the cap even if the engine said "complete"', () => {
expect(shouldAutoContinue('complete', 'x'.repeat(200), 4000, 4096)).toBe(true);
});
it('continues when the answer plainly ends mid-sentence (engine reason unclear)', () => {
expect(shouldAutoContinue('unknown', '당신은 복잡한 아이디어나 목표를 구체적인 실행 계획과 체계적인 문서화로', 60, 4096)).toBe(true);
expect(shouldAutoContinue('complete', 'the implementation continues here and we still need to', 100, 4096)).toBe(true);
});
it('does NOT continue from a tiny fragment or a complete-looking answer', () => {
expect(shouldAutoContinue('output-limit', 'short', 4000, 4096)).toBe(false); // < 24 chars
expect(shouldAutoContinue('complete', '이것은 완전히 끝난 답변이고 마침표도 붙어 있습니다.', 100, 4096)).toBe(false);
expect(shouldAutoContinue('unknown', '이것은 마침표 없이 끝나는 한국어 문장이고 충분히 길다고 본다', 100, 4096)).toBe(false);
});
it('does NOT continue on stop reasons that more text cannot fix', () => {
expect(shouldAutoContinue('context-overflow', 'x'.repeat(200), 4000, 4096)).toBe(false);
expect(shouldAutoContinue('error', 'x'.repeat(200), 4000, 4096)).toBe(false);
expect(shouldAutoContinue('user-stopped', 'x'.repeat(200), 4000, 4096)).toBe(false);
expect(shouldAutoContinue('tool-calls', 'x'.repeat(200), 4000, 4096)).toBe(false);
});
});
describe('responseRecovery.mergeContinuationParts', () => {
it('handles empty inputs', () => {
expect(mergeContinuationParts('', 'hello')).toBe('hello');
expect(mergeContinuationParts('hello', '')).toBe('hello');
expect(mergeContinuationParts('', '')).toBe('');
});
it('joins with a paragraph break when the previous part ended cleanly', () => {
expect(mergeContinuationParts('첫 번째 부분.', '두 번째 부분.')).toBe('첫 번째 부분.\n\n두 번째 부분.');
});
it('removes a verbatim overlap the continuation re-stated, splicing mid-sentence', () => {
const a = 'the answer continues here and here';
const b = 'continues here and here, then more';
expect(mergeContinuationParts(a, b)).toBe('the answer continues here and here, then more');
});
});
describe('responseRecovery.buildContinuationUserPrompt', () => {
it('includes the original question and the tail of the answer so far', () => {
const p = buildContinuationUserPrompt('원래 질문은 무엇인가?', 'a'.repeat(50) + 'TAIL_MARKER');
expect(p).toContain('원래 질문은 무엇인가?');
expect(p).toContain('TAIL_MARKER');
expect(p).toMatch(/continue/i);
});
it('truncates a long answer-so-far to its tail', () => {
const long = 'HEAD_MARKER' + 'b'.repeat(3000) + 'TAIL_MARKER';
const p = buildContinuationUserPrompt('q', long, 1400);
expect(p).toContain('TAIL_MARKER');
expect(p).not.toContain('HEAD_MARKER');
expect(p).toContain('…');
});
});