09-24 실측 2건: get(읽기)만 하고 add_note 없이 "메모탭에 저장했습니다" 보고.
프롬프트 규칙(b327eef)으로도 안 잡혀 MESSAGING POST-CHECK과 같은 패턴으로
가드 승격. ws_ 세션에서 저장 요청(메모/개요/부품+변경동사) + 답변이 저장
클레임(종결접미사 필수 — "저장했너?"/"저장했던" 제외) + 이번 턴에 변경
action(get/list 제외) 없음 → 1회 강제 재유도.
"이미 이전 턴에 저장돼 있습니다"는 재유도 답변에서 명시적으로 허용해
오탐(이전 턴 저장 상태 보고)이 무한루프 안 걸리게 함. 회귀테스트 7건.
Co-Authored-By: Claude Code <noreply@anthropic.com>
291 lines
15 KiB
TypeScript
291 lines
15 KiB
TypeScript
/**
|
|
* retry-decisions.test.ts
|
|
*
|
|
* These three checks decide whether an answer the model already wrote gets thrown away.
|
|
* They all fail OPEN — a regression produces no error and no log line, just an unverified
|
|
* answer delivered to the user as fact. That is precisely the class of bug this codebase has
|
|
* repeatedly shipped (see the 2026-07-29 log audit), so both directions are pinned:
|
|
* must-retry cases AND must-not-retry cases.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
decideAutoRecover,
|
|
shouldForceMessagingRetry,
|
|
shouldForceWorkshopSaveRetry,
|
|
shouldForceEmptyGroundingRetry,
|
|
} from '../src/gateway/chat/retry-decisions';
|
|
|
|
const recover = (message: string, content: string, called: string[] = [], available: string[] = ['web_search']) =>
|
|
decideAutoRecover({ message, content, availableToolNames: available, calledToolNames: called });
|
|
|
|
describe('AUTO-RECOVER — 근거 없이 답한 경우 재시도', () => {
|
|
test('스펙 질문에 검색 없이 답하면 재시도', () => {
|
|
assert.equal(recover('RTX 5090 스펙 알려줘', 'VRAM은 32GB입니다.').shouldRetry, true);
|
|
});
|
|
|
|
test('[2026-07-29] 입력이 평범해도 답변에 지어낸 수치가 있으면 재시도', () => {
|
|
// 실제 사고 패턴: 질문은 캐주얼한데 답에 확정적 수치가 나옴.
|
|
const d = recover('이거 어떨까?', '초당 약 15~22 토큰 정도 나옵니다.');
|
|
assert.equal(d.shouldRetry, true);
|
|
assert.match(d.reason, /unverified spec/);
|
|
});
|
|
|
|
test('이미 검색했으면 재시도하지 않는다', () => {
|
|
assert.equal(recover('RTX 5090 스펙', 'VRAM 32GB입니다.', ['web_search']).shouldRetry, false);
|
|
});
|
|
|
|
test('무관한 도구를 불렀다고 무장해제되지 않는다', () => {
|
|
// 미끼 도구 하나로 남은 턴 전체가 풀리면 안 된다.
|
|
assert.equal(recover('RTX 5090 스펙', 'VRAM 32GB입니다.', ['coder_list_files']).shouldRetry, true);
|
|
});
|
|
|
|
test('개인 인프라 질문은 재시도 없음 — 검색해도 나올 수 없다', () => {
|
|
assert.equal(recover('지서버 사양이 어떻게 되지?', '3060 12GB 2장, 40GB RAM입니다.').shouldRetry, false);
|
|
});
|
|
|
|
test('코딩 요청 안의 숫자는 재시도를 유발하지 않는다', () => {
|
|
assert.equal(recover('파이썬 스크립트 만들어줘', '버퍼는 64GB로 잡았습니다.').shouldRetry, false);
|
|
});
|
|
|
|
test('숫자 없는 일반 대화는 재시도 없음', () => {
|
|
assert.equal(recover('안녕', '안녕하세요! 무엇을 도와드릴까요?').shouldRetry, false);
|
|
});
|
|
|
|
test('안전 거부 문구 + 도구 필요한 요청이면 재시도', () => {
|
|
const d = recover('이 사이트 좀 찾아줘', "I'm sorry, I can't help with that.");
|
|
assert.equal(d.shouldRetry, true);
|
|
});
|
|
|
|
// [2026-08-09] 실제 사고: "지금 태풍 돌핀 어디 있지?"에 도구를 하나도 안 부르고
|
|
// 좌표·기압·풍속을 단언했다(로그 12065행). 같은 수치가 일주일 전 답변(9119행)과
|
|
// 글자까지 동일해서, 검색이 아니라 대화 기억에서 나온 값임이 드러났다.
|
|
test('[2026-08-09] 태풍 위치를 검색 없이 답하면 재시도', () => {
|
|
const d = recover(
|
|
'지금 태풍 돌핀 어디 있지?',
|
|
'현재 태풍 돌핀의 위치는 북위 18.5°, 동경 150.0° 부근이며 중심기압 905hPa, 최대풍속 58m/s입니다.',
|
|
);
|
|
assert.equal(d.shouldRetry, true);
|
|
assert.equal(d.kind, 'search');
|
|
});
|
|
|
|
// 반대 방향이 더 중요하다: 관측 단위(℃/mm/m/s)를 가드에 추가했으므로, 도구로 받아온
|
|
// 정상 예보까지 되던지면 날씨 기능 자체가 무한 재검색에 빠진다.
|
|
test('[2026-08-09] 날씨 도구로 받은 기온·강수량 답변은 되던지지 않는다', () => {
|
|
assert.equal(
|
|
recover('런던 오늘 최고기온?', '런던의 오늘 최고기온은 31.5°C이고 강수량은 2mm 예상됩니다.', ['weather_openmeteo'])
|
|
.shouldRetry,
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('[2026-08-09] 검색 후의 태풍 답변도 되던지지 않는다', () => {
|
|
assert.equal(
|
|
recover('지금 태풍 돌핀 어디 있지?', '태풍 돌핀은 북위 18.5°, 동경 150.0° 부근에 있습니다.', ['web_search'])
|
|
.shouldRetry,
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('AUTO-RECOVER — 재생성을 낭비하지 않아야 하는 경우', () => {
|
|
// [2026-08-10] 로그에 실제로 잡힌 오탐. 사용자가 좌표를 직접 적어 물었고(그 좌표 자체가 한 턴
|
|
// 전 web_search 결과였다), 모델이 정답을 냈는데 답에 좌표를 되풀이했다는 이유로 재생성이 걸렸다.
|
|
// 버려진 답 대신 돌아온 검색 결과는 "농림통계연보(2005).hwp"였고, 결국 같은 답을 다시 썼다.
|
|
test('사용자가 질문에 직접 적은 숫자를 되풀이한 것만으로는 재시도하지 않는다', () => {
|
|
const d = recover('북위 27.7도, 동경 124.8도 여기가 어디쯤이지?', '북위 27.7도, 동경 124.8도는 일본 오키나와 제도 근처의 동중국해 해상입니다.');
|
|
assert.equal(d.shouldRetry, false);
|
|
});
|
|
|
|
test('사용자가 준 숫자여도 모델이 새 수치를 덧붙이면 여전히 재시도', () => {
|
|
// 가드가 통째로 무력화되면 안 된다 — 질문에 없던 905hPa가 검증 대상으로 남아야 한다.
|
|
const d = recover('북위 27.7도 여기가 어디야?', '북위 27.7도 부근이고, 중심기압은 905hPa입니다.');
|
|
assert.equal(d.shouldRetry, true);
|
|
});
|
|
|
|
test('한 자리 수는 지우지 않는다 — 가드 전체가 무력화되는 것을 막는다', () => {
|
|
// 사용자 메시지의 "2" 하나로 답변의 모든 "2"가 지워지면 32GB도 통과해버린다.
|
|
assert.equal(recover('2번은 어때?', 'VRAM은 32GB입니다.').shouldRetry, true);
|
|
});
|
|
|
|
// [2026-09-03] "눈향나무 사진 찾아줘" → search_images 정상 호출했는데 isFactualInfoRequest가
|
|
// 매칭돼 AUTO-RECOVER가 재프롬프트 → gemma가 search_images를 3번 호출하며 루프. search_images가
|
|
// 근거이고, 이미지 누락은 appendDroppedSearchImages가 복구하므로 재시도할 게 없다.
|
|
test('사진 요청 + search_images 호출이면 재시도하지 않는다', () => {
|
|
// 실제 로그: 모델이 사진 대신 눈향나무 설명 프로즈를 냈고, 그 안의 "5cm·30cm·500년"이
|
|
// looksLikeUnverifiedSpecClaim에 걸려 unverifiedSpecClaim→liveDataRequest로 재프롬프트됐다.
|
|
const specyProse = '눈향나무는 연간 약 5cm 자라며 최대 높이 30cm, 수령은 500년에 달합니다. 가격은 3만원 선입니다.';
|
|
assert.equal(recover('눈향나무 Juniperus chinensis var. sargentii 사진 찾아줘', specyProse, ['search_images'], ['search_images']).shouldRetry, false);
|
|
assert.equal(recover('강아지 사진 보여줘', specyProse, ['search_images'], ['search_images']).shouldRetry, false);
|
|
// 대조군: 같은 프로즈여도 search_images를 안 불렀으면 여전히 재시도(가드가 통째로 꺼지면 안 됨)
|
|
assert.equal(recover('눈향나무 사진 찾아줘', specyProse, ['coder_list_files'], ['search_images', 'web_search']).shouldRetry, true);
|
|
});
|
|
|
|
// [2026-08-10] nhc_active_storms/eonet_events가 GROUNDING_TOOL_PATTERN에 없어서, NHC 원본
|
|
// 수치로만 답한 태풍 턴은 hPa·m/s·북위가 전부 매칭되는데도 "근거 도구 미호출"로 취급됐다.
|
|
test('NHC/EONET로 근거를 댄 태풍 답변은 재시도하지 않는다', () => {
|
|
const answer = '중심기압 905hPa, 최대풍속 43m/s로 북위 18.5도 부근을 지나고 있습니다.';
|
|
assert.equal(recover('태풍 지금 위치', answer, ['nhc_active_storms']).shouldRetry, false);
|
|
assert.equal(recover('지금 진행 중인 자연재해 알려줘', answer, ['eonet_events']).shouldRetry, false);
|
|
// 대조군: 아무 근거 도구도 안 불렀으면 그대로 재시도여야 한다.
|
|
assert.equal(recover('태풍 지금 위치', answer, ['coder_list_files']).shouldRetry, true);
|
|
});
|
|
});
|
|
|
|
describe('AUTO-RECOVER — 어떤 도구를 요구할지 (kind)', () => {
|
|
test('기본은 검색', () => {
|
|
assert.equal(recover('오늘 뉴스', '어제 이런 일이...').kind, 'search');
|
|
});
|
|
|
|
test('브라우저 도구가 스키마에 있을 때만 browser로 분기', () => {
|
|
const msg = 'github.com 열어서 확인해줘';
|
|
assert.equal(recover(msg, '음...', [], ['web_search']).kind, 'search');
|
|
assert.equal(recover(msg, '음...', [], ['web_search', 'browser_open']).kind, 'browser');
|
|
});
|
|
|
|
test('브라우저 분기면 web_search를 이미 불렀어도 재시도한다 — 근거 도구가 다르다', () => {
|
|
// 거부 문구여야 게이트가 성립한다. 짧고 무의미한 답("음...")은 재시도 대상이 아님 —
|
|
// 회복할 게 없기 때문. 아래 테스트가 그 경계를 따로 고정한다.
|
|
const refusal = "I'm sorry, I can't use your computer to do that.";
|
|
const d = recover('github.com 열어줘', refusal, ['web_search'], ['browser_open']);
|
|
assert.equal(d.kind, 'browser');
|
|
assert.equal(d.shouldRetry, true, 'web_search는 브라우저 요청의 근거가 되지 못한다');
|
|
});
|
|
|
|
test('짧고 애매한 답변은 재시도 대상이 아니다 — 회복할 내용이 없다', () => {
|
|
assert.equal(recover('github.com 열어줘', '음...', [], ['browser_open']).shouldRetry, false);
|
|
});
|
|
});
|
|
|
|
describe('MESSAGING — 보냈다는 거짓 주장 차단', () => {
|
|
const send = (name: string, error = false) => [{ name, error }];
|
|
|
|
test('전송 요청 + 보냈다 주장 + 도구 미호출 → 재시도', () => {
|
|
assert.equal(shouldForceMessagingRetry({
|
|
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: [],
|
|
}), true);
|
|
});
|
|
|
|
test('실제로 보냈으면 재시도 없음', () => {
|
|
assert.equal(shouldForceMessagingRetry({
|
|
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: send('email_send'),
|
|
}), false);
|
|
});
|
|
|
|
test('전송이 실패했으면 "보냈다"는 여전히 거짓 — 재시도', () => {
|
|
assert.equal(shouldForceMessagingRetry({
|
|
message: '이메일 보내줘', candidateText: '메일 보냈습니다.', toolResults: send('email_send', true),
|
|
}), true);
|
|
});
|
|
|
|
test('보냈다고 하지 않았으면 재시도 없음', () => {
|
|
assert.equal(shouldForceMessagingRetry({
|
|
message: '이메일 보내줘', candidateText: '어떤 내용으로 보낼까요?', toolResults: [],
|
|
}), false);
|
|
});
|
|
|
|
test('전송 요청이 아니면 재시도 없음', () => {
|
|
assert.equal(shouldForceMessagingRetry({
|
|
message: '메일 읽어줘', candidateText: '3통 있습니다.', toolResults: [],
|
|
}), false);
|
|
});
|
|
});
|
|
|
|
describe('WORKSHOP SAVE — 저장했다는 거짓 주장 차단 (09-24 실측 2건)', () => {
|
|
// 실측: get만 하고 add_note 없이 "메모탭에 저장했습니다"라고 보고한 도표 정리 턴.
|
|
const read = [{ name: 'workshop_project', args: { action: 'get' }, error: false }];
|
|
const ws = { workshopSession: true };
|
|
|
|
test('get만 하고 저장했다 주장 → 재시도 (09-24 실측 그대로)', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모를 보기 쉽게 도표 형태로 다시 정리',
|
|
candidateText: '방금 정리해 드린 도표 내용을 프로젝트 메모 탭에 추가 저장했습니다.',
|
|
toolResults: read,
|
|
}), true);
|
|
});
|
|
|
|
test('에러난 add_note도 저장이 아니다 — 재시도', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모 저장해줘', candidateText: '저장했습니다.',
|
|
toolResults: [{ name: 'workshop_project', args: { action: 'add_note' }, error: true }],
|
|
}), true);
|
|
});
|
|
|
|
test('실제로 add_note를 호출했으면 재시도 없음', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모 저장해줘', candidateText: '저장했습니다.',
|
|
toolResults: [{ name: 'workshop_project', args: { action: 'add_note' }, error: false }],
|
|
}), false);
|
|
});
|
|
|
|
test('저장 클레임 없는 답변은 재시도 없음', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모를 도표 형태로 정리해줘', candidateText: '도표로 정리했습니다.',
|
|
toolResults: read,
|
|
}), false);
|
|
});
|
|
|
|
test('질문형/수식형("저장했너?", "저장했던")은 클레임이 아니다', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모를 정리해줘', candidateText: '메모탭에 저장했너? 저장했던 메모를 보여드릴까요?',
|
|
toolResults: read,
|
|
}), false);
|
|
});
|
|
|
|
test('미래시제("저장하겠습니다")는 클레임이 아니다', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
...ws, message: '메모를 정리해줘', candidateText: '도표 형태로 정리해서 저장하겠습니다.',
|
|
toolResults: read,
|
|
}), false);
|
|
});
|
|
|
|
test('ws_ 세션이 아니면 발동하지 않는다', () => {
|
|
assert.equal(shouldForceWorkshopSaveRetry({
|
|
workshopSession: false, message: '메모를 정리해서 저장해줘',
|
|
candidateText: '저장했습니다.', toolResults: read,
|
|
}), false);
|
|
});
|
|
});
|
|
|
|
describe('EMPTY-GROUNDING — 빈 검색결과로 답한 경우', () => {
|
|
test('검색은 했지만 전부 빈 결과면 재시도', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({
|
|
message: '오늘 뉴스',
|
|
toolResults: [{ name: 'news_search', result: '(no articles found)' }],
|
|
}), true);
|
|
});
|
|
|
|
test('쓸만한 결과가 하나라도 있으면 재시도 없음', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({
|
|
message: '오늘 뉴스',
|
|
toolResults: [
|
|
{ name: 'news_search', result: '(no articles found)' },
|
|
{ name: 'web_search', result: '[1] 실제 기사 제목' },
|
|
],
|
|
}), false);
|
|
});
|
|
|
|
test('검색을 아예 안 했으면 이 검사 대상이 아니다 (AUTO-RECOVER 담당)', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({ message: '오늘 뉴스', toolResults: [] }), false);
|
|
});
|
|
|
|
test('근거 도구가 아닌 것만 돌았으면 대상 아님', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({
|
|
message: '오늘 뉴스', toolResults: [{ name: 'coder_list_files', result: '' }],
|
|
}), false);
|
|
});
|
|
|
|
test('라이브 데이터 질문이 아니면 대상 아님', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({
|
|
message: '시 한 편 써줘', toolResults: [{ name: 'web_search', result: '' }],
|
|
}), false);
|
|
});
|
|
|
|
test('에러로 끝난 검색도 빈 결과로 친다', () => {
|
|
assert.equal(shouldForceEmptyGroundingRetry({
|
|
message: '내일 날씨', toolResults: [{ name: 'weather_kma', error: true, result: 'boom' }],
|
|
}), true);
|
|
});
|
|
});
|