인자 보정에 적용한 것(dbe34ba)과 같은 문제가 도구 선택 게이트에도 있었다.
날씨 대화 중에 "그럼 모레는?"이라고 하면 그 메시지에 날씨 단어가 없어서
weather_* 가 스키마에서 빠진다 — 어제 "향후 비소식" 사고와 같은 결말이 된다.
ToolScopeInput에 recentUserText(선택)를 추가하고, **날씨·뉴스·재난 게이트에만**
적용했다(사용자 결정). 이 셋은 "도구를 제공할까"만 정하므로 오래된 매치가
남더라도 스키마 토큰 몇백 개가 낭비될 뿐 답이 틀리지 않는다.
나머지 게이트는 의도적으로 현재 메시지에 그대로 둔다. 전부 넓히면 두 턴 전
코딩 단어 하나 때문에 무관한 턴에 coder 도구가 딸려온다 — 테스트로 박아뒀다.
recentUserText가 없으면 message로 폴백하므로, 이력을 갖지 않은 호출부는
이전과 완전히 동일하게 동작한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
13 KiB
TypeScript
243 lines
13 KiB
TypeScript
/**
|
|
* tool-scope.test.ts
|
|
*
|
|
* Which tools the model is offered on a given turn. Until 2026-07-29 this logic lived as a
|
|
* closure inside handleChat() and could only be exercised by booting the gateway and reading
|
|
* a token count out of an SSE event — so it had never been tested at all, and a mis-gated tool
|
|
* showed up only as a slightly larger prompt or a mysteriously missing capability.
|
|
*
|
|
* Two properties matter and they pull in opposite directions:
|
|
* 1. cost — a tool nobody needs must not ride along (that is why the gates exist)
|
|
* 2. reach — a tool the user clearly asked for must be there (a gate that is too tight
|
|
* silently removes a feature, which is far worse than a few wasted tokens)
|
|
* Every test below pins one or the other.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { buildSkillToolFilter, selectToolsForTurn, codeAiBlockedTools, bootAllowedTools } from '../src/gateway/chat/tool-scope';
|
|
|
|
const base = {
|
|
sessionId: 'default',
|
|
userWorkspace: null as string | null,
|
|
isSkillEnabledForUser: () => false,
|
|
};
|
|
/** 모든 스킬이 켜진 상태 — 스킬+키워드 두 조건을 함께 요구하는 게이트를 검증할 때 사용. */
|
|
const allSkills = { ...base, isSkillEnabledForUser: () => true };
|
|
|
|
const filterFor = (message: string, extra: Partial<typeof base> = {}) =>
|
|
buildSkillToolFilter({ ...base, ...extra, message });
|
|
|
|
describe('키워드 게이트 — 관련 없는 턴에서 도구가 따라붙지 않는다', () => {
|
|
test('평범한 인사에는 앱 전용 도구가 전부 빠진다', () => {
|
|
const f = filterFor('안녕');
|
|
for (const name of ['weather_kma', 'news_search', 'email_list', 'create_presentation',
|
|
'korean_law_search', 'sqlite_query', 'image_edit', 'schedule_job',
|
|
'coder_read_file', 'shell', 'excel_read', 'eonet_events']) {
|
|
assert.equal(f(name), false, `${name} 이(가) 인사말 턴에 노출됨`);
|
|
}
|
|
});
|
|
|
|
test('핵심 도구는 항상 남는다', () => {
|
|
const f = filterFor('안녕');
|
|
for (const name of ['web_search', 'web_fetch', 'memory_read', 'python_eval']) {
|
|
assert.equal(f(name), true, `${name} 이(가) 사라짐`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('키워드 게이트 — 필요할 때 확실히 나타난다', () => {
|
|
test('뉴스 요청', () => {
|
|
assert.equal(filterFor('오늘 미국 주요 뉴스')('news_search'), true);
|
|
});
|
|
|
|
test('기본 날씨 2종은 스킬 없이 키워드만으로 열린다 — 일상 질문이므로', () => {
|
|
// "내일 날씨"는 전문가 기능이 아니다. 이 둘이 한국(관측)+해외(강수확률)를 커버한다.
|
|
const f = filterFor('내일 날씨 어때');
|
|
assert.equal(f('weather_kma'), true);
|
|
assert.equal(f('weather_openmeteo'), true);
|
|
});
|
|
|
|
test('"날씨"라는 단어 없이 묻는 날씨 질문 — 2026-08-12 실사고', () => {
|
|
// 실제 사고: "향후 비소식은 없나?"에 날씨 키워드가 하나도 안 걸려 weather_* 가 스키마에서
|
|
// 빠졌고, 모델은 web_search로 2019년 기사를 물어와 "예보를 확인할 수 없다"고 답했다.
|
|
// 이어서 "기상청에 알아봐"라고 명시했는데 그것도 안 걸려 두 번째 턴까지 같은 실패.
|
|
for (const msg of ['향후 비소식은 없나?', '오늘 영남지역 비소식 있나?', '기상청에 알아봐', '내일 비 와요?', '주말에 소나기 오나']) {
|
|
assert.equal(filterFor(msg)('weather_kma'), true, msg);
|
|
}
|
|
});
|
|
|
|
test('"기상현상 + 소식/예보/전망" 은 한 패턴으로 잡는다', () => {
|
|
// 현상별로 흩어 놓으면 다음 현상에서 또 빠진다 — 실제로 우박·안개·서리가 빠져 있었다.
|
|
for (const msg of ['비소식', '눈 소식', '태풍소식', '우박 소식', '안개 소식', '서리 소식', '바람 전망', '강수 상황']) {
|
|
assert.equal(filterFor(msg)('weather_kma'), true, msg);
|
|
}
|
|
});
|
|
|
|
test('"소식"·"전망" 단독은 날씨가 아니다', () => {
|
|
for (const msg of ['회사 소식 있어?', '업계 소식 알려줘', '새로운 소식 없나', '실적 전망 어때']) {
|
|
assert.equal(filterFor(msg)('weather_kma'), false, msg);
|
|
}
|
|
});
|
|
|
|
test('"비"는 문맥형으로만 잡는다 — 비용·비교·준비에 걸리면 안 된다', () => {
|
|
for (const msg of ['서버 비용이 얼마야', 'A와 B 비교해줘', '발표 준비 도와줘', '비트코인 시세']) {
|
|
assert.equal(filterFor(msg)('weather_kma'), false, msg);
|
|
}
|
|
});
|
|
|
|
test('전문 날씨 도구는 여전히 스킬을 요구한다', () => {
|
|
// 재분석·기후 아카이브(ERA5/CMIP6/CDS/NASA POWER)와 대기질은 일상 질문이 아니다.
|
|
const f = filterFor('내일 날씨 어때');
|
|
for (const n of ['weather_era5', 'weather_cmip6', 'weather_cds', 'weather_nasa_power', 'weather_airkorea', 'weather_search']) {
|
|
assert.equal(f(n), false, `${n} 이(가) 스킬 없이 노출됨`);
|
|
}
|
|
assert.equal(buildSkillToolFilter({ ...allSkills, message: '내일 날씨 어때' })('weather_era5'), true);
|
|
});
|
|
|
|
test('날씨 키워드가 없으면 기본 날씨 도구도 안 열린다', () => {
|
|
assert.equal(filterFor('안녕')('weather_kma'), false);
|
|
assert.equal(filterFor('안녕')('weather_openmeteo'), false);
|
|
});
|
|
|
|
test('코드 관련 표현이면 편집/셸 도구가 열린다', () => {
|
|
const f = filterFor('이 파이썬 스크립트 디버그해줘');
|
|
assert.equal(f('coder_read_file'), true);
|
|
assert.equal(f('shell'), true);
|
|
});
|
|
|
|
test('이메일 / DB / 엑셀 / 일정', () => {
|
|
assert.equal(filterFor('메일 확인해줘')('email_list'), true);
|
|
assert.equal(filterFor('sqlite 쿼리 돌려줘')('sqlite_query'), true);
|
|
assert.equal(filterFor('엑셀 파일 읽어줘')('excel_read'), true);
|
|
assert.equal(filterFor('매일 아침에 반복 실행해줘')('schedule_job'), true);
|
|
});
|
|
|
|
test('첨부가 있어야 열리는 도구', () => {
|
|
assert.equal(filterFor('이거 읽어줘')('image_read'), false);
|
|
assert.equal(filterFor(' 이거 읽어줘')('image_read'), true);
|
|
assert.equal(filterFor('report.pdf 요약해줘')('pdf_read'), true);
|
|
});
|
|
|
|
test('[2026-07-29] ollama_web_* 는 명시 요청에만 — 평소엔 214토큰 낭비였다', () => {
|
|
assert.equal(filterFor('RTX 5090 검색해줘')('ollama_web_search'), false);
|
|
assert.equal(filterFor('올라마로 검색해줘')('ollama_web_search'), true);
|
|
assert.equal(filterFor('ollama web search 해줘')('ollama_web_fetch'), true);
|
|
});
|
|
|
|
test('위성/재난 도구', () => {
|
|
assert.equal(filterFor('안녕')('satellite_snapshot'), false);
|
|
assert.equal(filterFor('산불 위성사진 보여줘')('satellite_snapshot'), true);
|
|
});
|
|
|
|
// [2026-08-10] 실제 사고: "태풍 돌핀 지금 어디 있지?"에 일반 채팅에서 news_search와
|
|
// weather_map_screenshot(기상청/JMA 태풍 진로 지도) 둘 다 안 열려 있어서 web_search만
|
|
// 쓰다가 검색결과를 놓쳤다(실제로는 중국 상륙·100만명 대피 중이었는데 "이미 소멸했을
|
|
// 가능성" 답변). 위치/경로(weather_map_screenshot)와 피해상황(news_search) 둘 다
|
|
// 재해 키워드만으로 일반 채팅에서 열리게 했다 — "뉴스"라는 단어가 없어도.
|
|
test('재해 질문엔 일반 채팅에서도 weather_map_screenshot과 news_search가 열린다', () => {
|
|
const f = filterFor('태풍 돌핀 지금 어디 있지?');
|
|
assert.equal(f('weather_map_screenshot'), true, 'KMA/JMA 태풍 진로 지도가 안 열림');
|
|
assert.equal(f('news_search'), true, '"뉴스" 단어 없이도 재해 키워드로 news_search가 열려야 함');
|
|
});
|
|
|
|
test('재해 키워드 없는 일반 채팅에서는 여전히 닫혀 있다', () => {
|
|
const f = filterFor('오늘 날씨 어때');
|
|
assert.equal(f('weather_map_screenshot'), false);
|
|
assert.equal(f('news_search'), false);
|
|
});
|
|
});
|
|
|
|
describe('앱 세션 게이트 — 탭 안에서는 키워드 없이도 열려 있어야 한다', () => {
|
|
test('날씨 앱 탭에서는 후속 질문에 키워드가 없어도 도구가 유지된다', () => {
|
|
// "내일은?" 같은 후속엔 날씨 키워드가 없다 — 세션 접두사가 그걸 메운다.
|
|
const f = buildSkillToolFilter({ ...base, sessionId: 'wt_abc123', message: '내일은?' });
|
|
assert.equal(f('weather_kma'), true);
|
|
assert.equal(f('weather_map_screenshot'), true);
|
|
});
|
|
|
|
test('일반 세션에서는 windy 스크린샷이 열리지 않는다', () => {
|
|
assert.equal(buildSkillToolFilter({ ...allSkills, message: '날씨 알려줘' })('weather_map_screenshot'), false);
|
|
});
|
|
|
|
test('변호사 / 회계 앱 탭', () => {
|
|
assert.equal(buildSkillToolFilter({ ...base, sessionId: 'lw_1', message: '이거 봐줘' })('korean_law_search'), true);
|
|
assert.equal(buildSkillToolFilter({ ...base, sessionId: 'ac_1', message: '이거 봐줘' })('excel_read'), true);
|
|
});
|
|
});
|
|
|
|
describe('휴면 도구 — 실사용 0건이거나 이 플랫폼에서 동작하지 않는 것들', () => {
|
|
test('오케스트레이션/브라우저 도구는 어떤 문구로도 열리지 않는다', () => {
|
|
const f = filterFor('브라우저 열어서 작업 시작해줘');
|
|
assert.equal(f('spawn_subagent'), false);
|
|
assert.equal(f('start_task'), false);
|
|
assert.equal(f('browser_open'), false);
|
|
});
|
|
});
|
|
|
|
describe('selectToolsForTurn — 세션 종류별 분기', () => {
|
|
const tools = ['web_search', 'coder_read_file', 'image_edit', 'start_task', 'shell', 'memory_read']
|
|
.map(n => ({ type: 'function', function: { name: n } }));
|
|
const names = (r: any[]) => r.map(t => t.function.name).sort();
|
|
const flags = { isBootStartupTurn: false, isCodeAiSession: false, isTranslateSession: false, isProjSession: false };
|
|
|
|
test('부팅 턴은 읽기 전용 두 개만', () => {
|
|
const r = selectToolsForTurn(tools, { ...base, message: 'boot' }, { ...flags, isBootStartupTurn: true });
|
|
assert.deepEqual(names(r), ['coder_read_file']);
|
|
assert.ok(bootAllowedTools.has('coder_list_files'));
|
|
});
|
|
|
|
test('번역/프로젝트 세션은 도구 없음 — 엄격한 출력 형식을 도구 호출이 깨뜨리지 않게', () => {
|
|
assert.deepEqual(selectToolsForTurn(tools, { ...base, message: 'x' }, { ...flags, isTranslateSession: true }), []);
|
|
assert.deepEqual(selectToolsForTurn(tools, { ...base, message: 'x' }, { ...flags, isProjSession: true }), []);
|
|
});
|
|
|
|
test('코드 에디터 세션은 차단 목록만 제외하고 전부 통과 (키워드 게이트 미적용)', () => {
|
|
const r = selectToolsForTurn(tools, { ...base, message: '아무 말' }, { ...flags, isCodeAiSession: true });
|
|
// 키워드가 없어도 coder_/shell 이 살아있어야 한다 — 에디터에선 매 턴이 코드 맥락이다.
|
|
assert.ok(names(r).includes('coder_read_file'));
|
|
assert.ok(names(r).includes('shell'));
|
|
assert.ok(!names(r).includes('start_task'));
|
|
assert.ok(!names(r).includes('image_edit'));
|
|
assert.ok(codeAiBlockedTools.has('image_edit'));
|
|
});
|
|
|
|
test('일반 채팅은 키워드 필터를 탄다', () => {
|
|
const r = selectToolsForTurn(tools, { ...base, message: '안녕' }, flags);
|
|
assert.deepEqual(names(r), ['memory_read', 'web_search']);
|
|
});
|
|
});
|
|
|
|
describe('후속 요청은 앞 턴의 주제를 물려받는다 — 날씨·뉴스 게이트만', () => {
|
|
// 2026-08-12 사용자 요청. "그럼 모레는?"에는 날씨 단어가 없지만 앞 턴이 날씨였으면
|
|
// 도구가 남아 있어야 한다. 이 게이트들은 "도구를 제공할까"만 정하므로, 오래된 매치가
|
|
// 남더라도 스키마 토큰 몇백 개가 낭비될 뿐 답이 틀리지는 않는다.
|
|
const withHistory = (recentUserText: string) =>
|
|
buildSkillToolFilter({ ...base, message: '그럼 모레는?', recentUserText });
|
|
|
|
test('앞 턴이 날씨면 후속 요청에도 날씨 도구가 남는다', () => {
|
|
assert.equal(withHistory('내일 날씨 어때\n그럼 모레는?')('weather_kma'), true);
|
|
});
|
|
|
|
test('앞 턴이 뉴스면 후속 요청에도 news_search가 남는다', () => {
|
|
assert.equal(withHistory('오늘 미국 뉴스\n더 보여줘')('news_search'), true);
|
|
});
|
|
|
|
test('앞 턴도 무관하면 열리지 않는다', () => {
|
|
assert.equal(withHistory('이 코드 고쳐줘\n그럼 모레는?')('weather_kma'), false);
|
|
});
|
|
|
|
test('recentUserText가 없으면 이전과 똑같이 동작한다', () => {
|
|
assert.equal(filterFor('그럼 모레는?')('weather_kma'), false);
|
|
assert.equal(filterFor('내일 날씨 어때')('weather_kma'), true);
|
|
});
|
|
|
|
test('날씨·뉴스 외의 게이트는 넓히지 않는다 — 두 턴 전 코딩 단어가 도구를 끌고 오면 안 된다', () => {
|
|
const f = buildSkillToolFilter({
|
|
...base, message: '오늘 날씨 어때', recentUserText: '파이썬 함수 버그 고쳐줘\n오늘 날씨 어때',
|
|
});
|
|
assert.equal(f('weather_kma'), true);
|
|
assert.equal(f('run_command'), false);
|
|
});
|
|
});
|