Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/core/src/__tests__/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ describe('generalizedErrorMessageForLocale', () => {
const timeout = new Error('request timeout after 30s');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'en'), 'Request timed out');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'zh-CN'), '请求超时');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'ko'), '요청 시간 초과');
assert.equal(
generalizedErrorMessageForLocale(new Error('unclassified'), '操作失败', 'zh-CN'),
'操作失败',
Expand All @@ -385,6 +386,10 @@ describe('generalizedErrorMessageForLocale', () => {
generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-TW'),
'網路錯誤',
);
assert.equal(
generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'ko'),
'네트워크 오류',
);
}
});
});
Expand Down Expand Up @@ -480,6 +485,60 @@ describe('generalizedErrorMessageForLocale zh-CN', () => {
});
});

describe('generalizedErrorMessageForLocale ko', () => {
test('maps provider failures to Korean categories without leaking secrets', () => {
for (const [raw, expected] of [
['Request timeout after 30s', '요청 시간 초과'],
['HTTP 429 Too Many Requests', '모델 속도 제한 초과'],
['OpenAI rate limit reached for model gpt-4', '모델 속도 제한 초과'],
['rate exceeded', '모델 속도 제한 초과'],
['401 Unauthorized', '인증 실패'],
['HTTP 403 forbidden', '인증 실패'],
['Authentication failed', '인증 실패'],
['HTTP 500 Internal Server Error', '모델 제공자 오류'],
['Provider returned 503', '모델 제공자 오류'],
['Bad gateway 502', '모델 제공자 오류'],
['fetch failed', '네트워크 오류'],
['ECONNREFUSED', '네트워크 오류'],
['ENOTFOUND api.example.test', '네트워크 오류'],
['network unreachable', '네트워크 오류'],
['something weird happened', '작업에 실패했습니다'],
['401 Authorization: Bearer sk-live-secret-token-value', '인증 실패'],
]) {
const message = generalizedErrorMessageForLocale(new Error(raw), '작업에 실패했습니다', 'ko');
assert.equal(message, expected);
assert.match(message, /[가-힣]/);
assert.doesNotMatch(message, /sk-live-secret-token-value/);
}
assert.equal(
generalizedErrorMessageForLocale('non-Error string input', '작업에 실패했습니다', 'ko'),
'작업에 실패했습니다',
);
});

test('uses a caller-supplied Korean fallback for unknown errors', () => {
assert.equal(
generalizedErrorMessageForLocale(
new Error('something weird happened'),
'세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.',
'ko',
),
'세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.',
);
});

test('does not mistake runtime authority errors for authentication failures', () => {
assert.equal(
generalizedErrorMessageForLocale(
new Error('Conversation copy contains durable runtime authority facts'),
'이 컨텍스트로는 새 세션을 만들 수 없습니다.',
'ko',
),
'이 컨텍스트로는 새 세션을 만들 수 없습니다.',
);
});
});

describe('localized generalized error messages', () => {
test('routes one shared classification through each locale catalog', () => {
const error = new Error('HTTP 503 from provider');
Expand All @@ -489,6 +548,7 @@ describe('localized generalized error messages', () => {
);
assert.equal(generalizedErrorMessageForLocale(error, '後備', 'zh-CN'), '模型服务返回错误');
assert.equal(generalizedErrorMessageForLocale(error, '備援', 'zh-TW'), '模型服務傳回錯誤');
assert.equal(generalizedErrorMessageForLocale(error, '대체 문구', 'ko'), '모델 제공자 오류');
});

test('uses Taiwan terminology for Traditional Chinese categories', () => {
Expand All @@ -508,6 +568,10 @@ describe('localized generalized error messages', () => {
const error = new Error('something weird happened');
assert.equal(generalizedErrorMessageForLocale(error, '简中后备', 'zh-CN'), '简中后备');
assert.equal(generalizedErrorMessageForLocale(error, '繁中備援', 'zh-TW'), '繁中備援');
assert.equal(
generalizedErrorMessageForLocale(error, '한국어 대체 문구', 'ko'),
'한국어 대체 문구',
);
assert.equal(
generalizedErrorMessageForLocale(error, 'English fallback', 'en'),
'English fallback',
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/__tests__/relative-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ describe('relative timestamp labels', () => {

for (const ageMs of [0, 1_000, 30_000, 59_999]) {
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'en'), 'just now');
assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
}

assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh-CN'), '1分钟前');
assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'ko'), '1분 전');
assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago');
});

Expand All @@ -54,7 +58,7 @@ describe('relative timestamp labels', () => {
});

it('uses scan-friendly units for sidebar timestamps', () => {
for (const locale of ['zh-CN', 'en'] as const) {
for (const locale of ['zh-CN', 'ko', 'en'] as const) {
for (const [ageMs, expected] of [
[60_000, '1min'],
[46 * 60_000, '46min'],
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/__tests__/tool-quiet-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ describe('formatToolInvocationLine', () => {
),
'選哪個方案? 等 2 問',
);
assert.equal(
formatToolInvocationLine(
{
toolName: 'AskUserQuestion',
args: { questions: [{ question: '어느 방안을 고를까요?' }, { question: '계속할까요?' }] },
},
'ko',
),
'어느 방안을 고를까요? 외 1개',
);
});

it('keeps the ScheduledTask title headline', () => {
Expand Down Expand Up @@ -184,6 +194,11 @@ describe('projectToolArgsPreview', () => {
assert.ok(line !== undefined);
assert.match(line, /后台终端交互/);
assert.match(line, /80x24/);

const koLine = formatToolInvocationLine({ toolName: 'WriteStdin', args: preview }, 'ko');
assert.ok(koLine !== undefined);
assert.match(koLine, /백그라운드 터미널 상호작용/);
assert.match(koLine, /80x24/);
});

it('returns undefined when nothing displayable exists', () => {
Expand Down
51 changes: 48 additions & 3 deletions packages/core/src/__tests__/ui-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,18 @@ import {

describe('UI locale', () => {
it('accepts only the supported resolved locales and preferences', () => {
assert.equal(['zh-CN', 'zh-TW', 'en'].every(isUiLocale), true);
assert.equal(['zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocale), true);
assert.equal(isUiLocale('zh'), false);
assert.equal(['auto', 'zh-CN', 'zh-TW', 'en'].every(isUiLocalePreference), true);
assert.equal(isUiLocale('ko-KR'), false);
assert.equal(['auto', 'zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocalePreference), true);
});

it('normalizes the legacy persisted preference without widening the locale contract', () => {
assert.equal(normalizeUiLocalePreference('zh'), 'zh-CN');
assert.equal(normalizeUiLocalePreference('zh-TW'), 'zh-TW');
assert.equal(normalizeUiLocalePreference('ko'), 'ko');
assert.equal(normalizeUiLocalePreference('auto'), 'auto');
assert.equal(normalizeUiLocalePreference('ko-KR'), 'auto');
assert.equal(normalizeUiLocalePreference('unsupported'), 'auto');
});

Expand All @@ -54,6 +58,17 @@ describe('UI locale', () => {
[['zh-HK'], 'zh-TW'],
[['zh_MO'], 'zh-TW'],
[['zh_TW.UTF-8'], 'zh-TW'],
[['ko'], 'ko'],
[['ko-KR'], 'ko'],
[['ko_KR'], 'ko'],
[['KO-Kr'], 'ko'],
[['ko_KR.UTF-8'], 'ko'],
[['ko-Kore-KR'], 'ko'],
[['ko', 'ko-KR', 'en'], 'ko'],
[['en', 'ko'], 'en'],
[['ko', 'en'], 'ko'],
[['fr-FR', 'ko-KR'], 'ko'],
[['kok-IN'], 'en'],
[['fr-FR', 'en-US'], 'en'],
[[], 'en'],
] as const) {
Expand All @@ -64,18 +79,41 @@ describe('UI locale', () => {

it('resolves explicit preferences and overrides before the system locale', () => {
assert.equal(resolveUiLocale('auto', 'zh-TW'), 'zh-TW');
assert.equal(resolveUiLocale('auto', 'ko'), 'ko');
assert.equal(resolveUiLocale('zh-CN', 'zh-TW'), 'zh-CN');
assert.equal(resolveUiLocale('ko', 'en'), 'ko');
assert.equal(resolveUiLocale('en', 'ko'), 'en');
assert.equal(resolveUiLocale('zh-CN', 'zh-CN', 'en'), 'en');
assert.equal(resolveUiLocale('auto', 'ko', 'zh-TW'), 'zh-TW');
});

it('keeps every locale guard and formatter in step with UI_LOCALES', () => {
// `ko` is the first locale whose Intl tag is not its own name, so this can
// no longer assert identity. Pinning the table keeps a locale added later
// from silently reaching `Intl` without a deliberate tag.
const intlTags: Record<(typeof UI_LOCALES)[number], string> = {
'zh-CN': 'zh-CN',
'zh-TW': 'zh-TW',
ko: 'ko-KR',
en: 'en',
};
for (const locale of UI_LOCALES) {
assert.ok(isUiLocale(locale), locale);
assert.equal(resolveSystemUiLocale([locale]), locale);
assert.equal(uiLocaleToIntlLocale(locale), locale);
assert.equal(uiLocaleToIntlLocale(locale), intlTags[locale]);
}
const intlLocales = UI_LOCALES.map(uiLocaleToIntlLocale);
assert.equal(new Set(intlLocales).size, UI_LOCALES.length);
for (const tag of intlLocales) {
assert.equal(new Intl.Locale(tag).baseName, tag, tag);
}
});

it('maps ko onto the region-qualified Intl tag', () => {
assert.equal(uiLocaleToIntlLocale('ko'), 'ko-KR');
assert.equal(uiLocaleToIntlLocale('en'), 'en');
assert.equal(uiLocaleToIntlLocale('zh-CN'), 'zh-CN');
assert.equal(uiLocaleToIntlLocale('zh-TW'), 'zh-TW');
});
});

Expand All @@ -87,12 +125,14 @@ describe('UI message catalogs', () => {
}>()({
en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } },
'zh-CN': { title: '状态', detail: { ready: '就绪' } },
ko: { title: '상태', detail: { ready: '준비됨' } },
});

assert.deepEqual(resolveUiMessageCatalog(catalog), {
en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } },
'zh-CN': { title: '状态', detail: { ready: '就绪', waiting: 'Waiting' } },
'zh-TW': { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } },
ko: { title: '상태', detail: { ready: '준비됨', waiting: 'Waiting' } },
});
});

Expand All @@ -101,6 +141,11 @@ describe('UI message catalogs', () => {

assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool');
assert.equal(formatUiMessage(template, { count: 3 }, 'en'), '3 tools');

// Korean has one plural form; both counts take the `other` branch.
const koTemplate = '{count, plural, other {도구 #개}}';
assert.equal(formatUiMessage(koTemplate, { count: 1 }, 'ko'), '도구 1개');
assert.equal(formatUiMessage(koTemplate, { count: 3 }, 'ko'), '도구 3개');
});

it('fails soft for missing or inherited interpolation values', () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ export const GENERALIZED_ERROR_COPY = {
provider_error: 'Provider returned an error',
network_error: 'Network error',
},
ko: {
timeout: '요청 시간 초과',
rate_limited: '모델 속도 제한 초과',
auth_failed: '인증 실패',
provider_error: '모델 제공자 오류',
network_error: '네트워크 오류',
},
} satisfies UiCatalog<Record<GeneralizedErrorClass, string>>;

export function generalizedErrorMessageForLocale(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/relative-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const JUST_NOW: UiCatalog<string> = {
'zh-CN': '刚刚',
'zh-TW': '剛剛',
en: 'just now',
ko: '방금 전',
};

/** Future timestamps are treated as age zero and therefore display as just now. */
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tool-quiet-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ const STRINGS_BY_LOCALE: Record<UiLocale, QuietPreviewStrings> = {
bytes: (n) => `${n} bytes`,
moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''),
},
ko: {
backgroundTerminal: '백그라운드 터미널 상호작용',
empty: '(비어 있음)',
done: '완료',
notDone: '미완료',
replacements: (n) => `${n}군데`,
written: '기록됨',
bytes: (n) => `${n}바이트`,
moreQuestions: (total) => (total > 1 ? ` 외 ${total - 1}개` : ''),
},
};

function strings(locale: UiLocale): QuietPreviewStrings {
Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/ui-locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { IntlMessageFormat } from 'intl-messageformat';

/** Resolved locales supported by human-facing Maka clients. */
export const UI_LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
export const UI_LOCALES = ['zh-CN', 'zh-TW', 'ko', 'en'] as const;

export type UiLocale = (typeof UI_LOCALES)[number];

Expand Down Expand Up @@ -120,7 +120,7 @@ function isMessageRecord(value: unknown): value is Readonly<Record<string, unkno
}

export function isUiLocale(value: unknown): value is UiLocale {
return value === 'zh-CN' || value === 'zh-TW' || value === 'en';
return value === 'zh-CN' || value === 'zh-TW' || value === 'ko' || value === 'en';
}

export function isUiLocalePreference(value: unknown): value is UiLocalePreference {
Expand All @@ -142,6 +142,7 @@ export function resolveSystemUiLocale(languages: readonly string[] | null | unde
if (/^zh-hant(?:[-.]|$)/iu.test(normalized)) return 'zh-TW';
return 'zh-CN';
}
if (/^ko(?:[-.]|$)/iu.test(normalized)) return 'ko';
if (/^en(?:[-.]|$)/iu.test(normalized)) return 'en';
}
return 'en';
Expand All @@ -163,9 +164,17 @@ export function resolveUiLocale(
return preference === 'auto' ? systemLocale : preference;
}

/** Locale identifier used by every locale-sensitive Intl formatter. */
export function uiLocaleToIntlLocale(locale: UiLocale): UiLocale {
return locale;
/**
* Locale identifier used by every locale-sensitive Intl formatter.
*
* Most supported locales are already the tag `Intl` wants, so this is identity
* for them. `ko` is not: the bare tag leaves the region open, and the region is
* what picks Korean date, number, and plural formatting, so it is widened to
* `ko-KR` before it reaches a formatter. The return type stays a literal union
* of the tags actually emitted, so the set is visible in the signature.
*/
export function uiLocaleToIntlLocale(locale: UiLocale): 'zh-CN' | 'zh-TW' | 'en' | 'ko-KR' {
return locale === 'ko' ? 'ko-KR' : locale;
}

/** Copy for a wire code, or undefined when a newer producer sent one this catalog does not know. */
Expand Down
Loading