/** * weather-multi-location.test.ts * * What weather_openmeteo does when `location` holds several cities instead of one. * * Asked to compare cities, models batch them into a single argument * ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing. * * First fix (2026-08-09): a bare "위치를 찾을 수 없습니다" gave the model no reason to do * anything but reissue the identical call, so the turn looped until it ran out of rounds * (observed live 09:09). Rewrote the error to name every city and tell the model to call once * per city instead. * * That undershot too (2026-08-10, real incident): asked for 5 cities, the model retried only 2 * after the improved error and silently dropped the other 3 — no fabrication, just an * incomplete answer ("다른 도시가 더 필요하시면 말씀해 주세요" instead of finishing the list it * had already committed to). Prose telling the model what to do kept being followed partially, * not completely, so weather_openmeteo now fetches every city itself on a detected batch and * returns one combined report — there's no follow-up call left for the model to skip. * * These tests hit the real Open-Meteo geocoding/forecast API, so they are skipped when it is * unreachable rather than failing the suite on a network blip. */ import { test, describe, before } from 'node:test'; import assert from 'node:assert/strict'; import { weatherOpenMeteoTool } from '../src/tools/weather'; const ask = (location: string) => weatherOpenMeteoTool.execute({ location, variables: 'temperature_2m_max' }) as Promise<{ success: boolean; error?: string; stdout?: string; data?: { locations?: string[]; failed?: string[] }; }>; let online = false; before(async () => { try { const res = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=Seoul&count=1', { signal: AbortSignal.timeout(8_000), }); online = res.ok; } catch { online = false; } if (!online) console.log(' (geocoding API 접속 불가 — 네트워크 의존 테스트 건너뜀)'); }); describe('배치 입력은 도구가 직접 전부 조회해 하나로 합친다', () => { test('여러 도시를 한 번에 넘기면 전부 조회해서 하나의 결과로 합친다', async (t) => { if (!online) return t.skip('offline'); const r = await ask('London, Berlin, Paris, Rome, Madrid'); // 2026-08-10 이전엔 여기서 success=false + 도시별로 다시 부르라는 안내였다. 그 안내를 // 모델이 부분적으로만 따라서(5개 중 2개만 재시도) 나온 실제 사고 이후, 도구가 직접 전부 // 조회하도록 바꿨다 — 모델이 빠뜨릴 재시도 자체가 없어야 한다. assert.equal(r.success, true); const stdout = r.stdout ?? ''; for (const city of ['London', 'Berlin', 'Paris', 'Rome', 'Madrid']) { assert.ok(r.data?.locations?.some(l => l.includes(city)) || stdout.includes(city), `${city} 결과가 빠짐`); } // 5개 도시 결과가 구분 없이 뒤섞이면 안 되므로 구분선이 있어야 함. assert.ok(stdout.includes('─'), '도시별 구분선이 없음'); }); test('일부 도시만 실패해도 나머지는 정상 반환된다', async (t) => { if (!online) return t.skip('offline'); // looksLikeMultipleLocations는 배치 판별을 위해 첫 두 세그먼트가 각각 지오코딩되는지만 // 본다(그래야 "Springfield, Freedonia" 같은 단일 지명+미확인 수식어를 목록으로 오인하지 // 않음) — 그래서 실패할 도시는 세 번째 자리에 둔다. const r = await ask('London, Paris, Zzxqwv'); assert.equal(r.success, true, '일부 실패로 전체가 실패하면 안 됨'); assert.ok(r.data?.failed?.some(f => f.includes('Zzxqwv')), '실패한 도시가 failed 목록에 있어야 함'); assert.ok(r.data?.locations?.some(l => l.includes('London'))); assert.ok(r.data?.locations?.some(l => l.includes('Paris'))); }); }); describe('단일 위치는 목록으로 오해하지 않는다', () => { test('"City, Country"는 정상 조회된다', async (t) => { if (!online) return t.skip('offline'); assert.equal((await ask('Seoul, Korea')).success, true); }); test('"City,CountryCode"는 정상 조회된다', async (t) => { if (!online) return t.skip('offline'); assert.equal((await ask('London,GB')).success, true); }); test('쉼표가 있는 좌표는 정상 조회된다', async (t) => { if (!online) return t.skip('offline'); assert.equal((await ask('37.5,127.0')).success, true); }); }); describe('진짜 없는 지명은 기존 메시지를 유지한다', () => { test('배치가 아닌 미확인 지명에는 배치 안내를 붙이지 않는다', async (t) => { if (!online) return t.skip('offline'); const r = await ask('Zzxqwv'); assert.equal(r.success, false); assert.match(r.error ?? '', /위치를 찾을 수 없습니다/); assert.doesNotMatch(r.error ?? '', /한 번에 한 곳만/, '단일 지명에 배치 안내가 붙으면 안 됨'); }); });