Files
homeclaw/tests/reply-content.test.ts
T
kimandClaude Opus 5 bd64e0fc0d fix: 짧은 답변을 빈 응답으로 오판정하던 문제
handleChat()의 빈 응답 판정이 finalText.length < 5였다. 응답이 "없는 것"과
"짧은 것"을 뭉뚱그린 규칙이라, "2"·"네"·"서울" 같은 완결된 답변이 버려지고
"죄송합니다, 응답을 생성하지 못했습니다"로 대체됐다. 모델은 제대로 답했는데
사용자에게는 실패했다고 표시되는, 가장 눈에 띄는 형태의 실패였다.

진짜 신호는 길이가 아니라 문자/숫자의 유무다. sanitizeFinalReply()가 pptx
링크나 메타 문구를 걷어내면 구두점만 남을 수 있고, 폴백 메시지는 원래 그런
잔여물을 위한 것이다.

replyLooksEmpty()를 reply-content.ts로 분리했다(handle-chat.ts는 테스트에서
import가 불가능할 만큼 크다 — tool-scope/system-prompt를 빼낸 것과 같은 방향).
유니코드 property escape를 써서 모든 문자 체계를 커버하고, 덤으로 한글 뒤에서
\b가 매칭되지 않는 함정도 회피한다.

검증: 신규 테스트 10개 포함 180개 통과. 실제 채팅에서
1+1 → "2", 3x7 → "21", 수도 → "서울", 네/아니오 → "네." 정상 반환.
빈 문자열·공백·"..."·"**"·이모지만 있는 응답은 여전히 폴백 처리.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:04:43 +09:00

83 lines
3.3 KiB
TypeScript

/**
* reply-content.test.ts
*
* Whether a turn counts as having produced a reply. This decides if the user sees the model's
* answer or the generic "죄송합니다, 응답을 생성하지 못했습니다" fallback, so it fails in the
* most visible way possible: the model answers correctly and the user is told it didn't.
*
* The rule was `finalText.length < 5` until 2026-08-09, which is a proxy for the wrong thing.
* Two properties pull against each other:
* 1. short answers are still answers — "2", "네", "서울" must survive
* 2. stripped residue is not an answer — sanitizeFinalReply() can leave bare punctuation,
* and that is exactly what the fallback is for
* Every test below pins one or the other.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { replyLooksEmpty } from '../src/gateway/chat/reply-content';
describe('replyLooksEmpty — 짧지만 정당한 답변', () => {
// Observed live: "1+1은? 숫자만 답해" streamed the token "2", and the length rule
// then replaced it with the failure message.
test('한 자리 숫자 답변은 답변이다', () => {
assert.equal(replyLooksEmpty('2'), false);
});
test('한글 단답(네/예/아니오)은 답변이다', () => {
for (const reply of ['네', '예', '아니오', '응']) {
assert.equal(replyLooksEmpty(reply), false, `"${reply}" was treated as empty`);
}
});
test('짧은 고유명사 답변은 답변이다', () => {
assert.equal(replyLooksEmpty('서울'), false);
});
test('구두점이 붙은 단답도 답변이다', () => {
assert.equal(replyLooksEmpty('2.'), false);
assert.equal(replyLooksEmpty('네!'), false);
});
test('영문 단답과 소수점 숫자도 답변이다', () => {
assert.equal(replyLooksEmpty('OK'), false);
assert.equal(replyLooksEmpty('3.14'), false);
assert.equal(replyLooksEmpty('42'), false);
});
});
describe('replyLooksEmpty — 내용 없는 응답', () => {
test('빈 문자열/공백은 비어있다', () => {
assert.equal(replyLooksEmpty(''), true);
assert.equal(replyLooksEmpty(' '), true);
assert.equal(replyLooksEmpty('\n\n\t'), true);
});
test('null/undefined는 비어있다', () => {
assert.equal(replyLooksEmpty(null), true);
assert.equal(replyLooksEmpty(undefined), true);
});
// sanitizeFinalReply() strips pptx links and meta lines; a reply made only of those
// comes back as leftover punctuation, which is what the fallback message is for.
test('구두점/기호만 남은 잔여물은 비어있다', () => {
for (const residue of ['...', '**', '()', '[]', '-', '—', '·', '. . .']) {
assert.equal(replyLooksEmpty(residue), true, `"${residue}" was treated as content`);
}
});
});
describe('replyLooksEmpty — 비라틴 문자', () => {
// Guards the Unicode property escape: an ASCII-oriented rule (or a \b-based one, which
// never matches after Hangul) would mis-handle these.
test('한자/일본어/키릴 답변도 내용으로 인정한다', () => {
assert.equal(replyLooksEmpty('漢'), false);
assert.equal(replyLooksEmpty('はい'), false);
assert.equal(replyLooksEmpty('да'), false);
});
test('이모지만 있는 응답은 내용이 없다', () => {
assert.equal(replyLooksEmpty('🤔'), true);
});
});